From f0f5b68fd914ae50f1342797592090447b3c93a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Fri, 1 May 2026 09:33:37 +0200 Subject: [PATCH 01/25] =?UTF-8?q?refactor(deduplikator):=20zmie=C5=84=20na?= =?UTF-8?q?zw=C4=99=20IgnoredAuthor=20=E2=86=92=20IgnoredScientist?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pierwszy krok przygotowania pod tryb general — istniejący IgnoredAuthor był specyficzny dla PBN (FK→Scientist) i zwalniamy nazwę pod nowy model ignorujący autorów BPP w trybie ogólnym. --- src/bpp/system.py | 4 ++-- src/deduplikator_autorow/admin.py | 6 ++--- ...9_rename_ignoredauthor_ignoredscientist.py | 23 +++++++++++++++++++ src/deduplikator_autorow/models.py | 8 +++---- src/deduplikator_autorow/tasks.py | 4 ++-- .../duplicate_authors.html | 2 +- src/deduplikator_autorow/utils/finders.py | 4 ++-- src/deduplikator_autorow/views.py | 12 +++++----- 8 files changed, 43 insertions(+), 20 deletions(-) create mode 100644 src/deduplikator_autorow/migrations/0009_rename_ignoredauthor_ignoredscientist.py diff --git a/src/bpp/system.py b/src/bpp/system.py index 17be57ff6..210421b15 100644 --- a/src/bpp/system.py +++ b/src/bpp/system.py @@ -78,7 +78,7 @@ from bpp.models.struktura import Jednostka_Wydzial from bpp.models.system import Charakter_PBN from bpp.models.wydawca import Poziom_Wydawcy, Wydawca -from deduplikator_autorow.models import IgnoredAuthor, LogScalania, NotADuplicate +from deduplikator_autorow.models import IgnoredScientist, LogScalania, NotADuplicate from dynamic_columns.models import ModelAdmin, ModelAdminColumn from ewaluacja_common.models import Rodzaj_Autora from ewaluacja_liczba_n.models import IloscUdzialowDlaAutoraZaRok, LiczbaNDlaUczelni @@ -189,7 +189,7 @@ RozbieznosciZrodelView, NotADuplicate, LogScalania, - IgnoredAuthor, + IgnoredScientist, ], "indeks autorów": [Autor, Autor_Jednostka], "administracja": [ diff --git a/src/deduplikator_autorow/admin.py b/src/deduplikator_autorow/admin.py index 8f4fe5e8d..0e200327e 100644 --- a/src/deduplikator_autorow/admin.py +++ b/src/deduplikator_autorow/admin.py @@ -9,7 +9,7 @@ from .models import ( DuplicateCandidate, DuplicateScanRun, - IgnoredAuthor, + IgnoredScientist, LogScalania, NotADuplicate, ) @@ -76,8 +76,8 @@ def get_author_last_name(self, obj): get_author_last_name.admin_order_field = "scientist_pk" -@admin.register(IgnoredAuthor) -class IgnoredAuthorAdmin(DynamicAdminFilterMixin, admin.ModelAdmin): +@admin.register(IgnoredScientist) +class IgnoredScientistAdmin(DynamicAdminFilterMixin, admin.ModelAdmin): list_display = [ "get_scientist_display", "get_autor_display", diff --git a/src/deduplikator_autorow/migrations/0009_rename_ignoredauthor_ignoredscientist.py b/src/deduplikator_autorow/migrations/0009_rename_ignoredauthor_ignoredscientist.py new file mode 100644 index 000000000..cff55c56e --- /dev/null +++ b/src/deduplikator_autorow/migrations/0009_rename_ignoredauthor_ignoredscientist.py @@ -0,0 +1,23 @@ +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("deduplikator_autorow", "0008_add_priority_field"), + ] + + operations = [ + migrations.RenameModel( + old_name="IgnoredAuthor", + new_name="IgnoredScientist", + ), + migrations.AlterModelOptions( + name="ignoredscientist", + options={ + "ordering": ["-created_on"], + "verbose_name": "Ignorowany Scientist (PBN)", + "verbose_name_plural": "Ignorowani Scientist (PBN)", + }, + ), + ] diff --git a/src/deduplikator_autorow/models.py b/src/deduplikator_autorow/models.py index 9def5b9ea..c0ccffc37 100644 --- a/src/deduplikator_autorow/models.py +++ b/src/deduplikator_autorow/models.py @@ -30,8 +30,8 @@ def __str__(self): return f"Autor {self.autor} (not duplicate) - {self.created_by}" -class IgnoredAuthor(models.Model): - """Authors that should be completely ignored in the deduplication process""" +class IgnoredScientist(models.Model): + """Scientists from PBN that should be completely ignored in deduplication""" scientist = models.OneToOneField( "pbn_api.Scientist", @@ -66,8 +66,8 @@ class IgnoredAuthor(models.Model): ) class Meta: - verbose_name = "Ignorowany autor" - verbose_name_plural = "Ignorowani autorzy" + verbose_name = "Ignorowany Scientist (PBN)" + verbose_name_plural = "Ignorowani Scientist (PBN)" ordering = ["-created_on"] def __str__(self): diff --git a/src/deduplikator_autorow/tasks.py b/src/deduplikator_autorow/tasks.py index 101027480..0fa4a5dd3 100644 --- a/src/deduplikator_autorow/tasks.py +++ b/src/deduplikator_autorow/tasks.py @@ -240,7 +240,7 @@ def scan_for_duplicates(self, user_id=None, min_confidence=MIN_CONFIDENCE_TO_STO """ from pbn_api.models import OsobaZInstytucji - from .models import DuplicateCandidate, DuplicateScanRun, IgnoredAuthor + from .models import DuplicateCandidate, DuplicateScanRun, IgnoredScientist logger.info("Starting duplicate scan task...") @@ -257,7 +257,7 @@ def scan_for_duplicates(self, user_id=None, min_confidence=MIN_CONFIDENCE_TO_STO logger.info(f"Deleted {deleted_count} existing candidates") ignored_scientist_ids = set( - IgnoredAuthor.objects.values_list("scientist_id", flat=True) + IgnoredScientist.objects.values_list("scientist_id", flat=True) ) osoby_query = OsobaZInstytucji.objects.select_related("personId").all() diff --git a/src/deduplikator_autorow/templates/deduplikator_autorow/duplicate_authors.html b/src/deduplikator_autorow/templates/deduplikator_autorow/duplicate_authors.html index 0f9e904e5..371c3544a 100644 --- a/src/deduplikator_autorow/templates/deduplikator_autorow/duplicate_authors.html +++ b/src/deduplikator_autorow/templates/deduplikator_autorow/duplicate_authors.html @@ -313,7 +313,7 @@

Obecnie: {{ ignored_authors_count }} ignorowanych

- Zobacz listę diff --git a/src/deduplikator_autorow/utils/finders.py b/src/deduplikator_autorow/utils/finders.py index 4fe01f48c..4270d70aa 100644 --- a/src/deduplikator_autorow/utils/finders.py +++ b/src/deduplikator_autorow/utils/finders.py @@ -3,7 +3,7 @@ """ from bpp.models import Autor -from deduplikator_autorow.models import IgnoredAuthor +from deduplikator_autorow.models import IgnoredScientist from pbn_api.models import OsobaZInstytucji, Scientist from .analysis import autor_ma_publikacje_z_lat @@ -36,7 +36,7 @@ def znajdz_pierwszego_autora_z_duplikatami( # noqa: C901 # Pobierz IDs ignorowanych autorów ignored_scientist_ids = list( - IgnoredAuthor.objects.values_list("scientist_id", flat=True) + IgnoredScientist.objects.values_list("scientist_id", flat=True) ) # Przeszukaj wszystkie rekordy OsobaZInstytucji, wykluczając określonych autorów diff --git a/src/deduplikator_autorow/views.py b/src/deduplikator_autorow/views.py index 8d6be892f..d3819abc5 100644 --- a/src/deduplikator_autorow/views.py +++ b/src/deduplikator_autorow/views.py @@ -22,7 +22,7 @@ from .models import ( DuplicateCandidate, DuplicateScanRun, - IgnoredAuthor, + IgnoredScientist, LogScalania, NotADuplicate, ) @@ -200,7 +200,7 @@ def duplicate_authors_view(request): # noqa: C901 # Common context not_duplicate_count = NotADuplicate.objects.count() - ignored_authors_count = IgnoredAuthor.objects.count() + ignored_authors_count = IgnoredScientist.objects.count() latest_pbn_download = PbnDownloadTask.get_latest_task() # Check PBN people data freshness @@ -510,7 +510,7 @@ def ignore_author(request): scientist = Scientist.objects.get(pk=scientist_id) # Check if already ignored - if IgnoredAuthor.objects.filter(scientist=scientist).exists(): + if IgnoredScientist.objects.filter(scientist=scientist).exists(): messages.warning( request, f"Autor {scientist} jest już oznaczony jako ignorowany." ) @@ -520,7 +520,7 @@ def ignore_author(request): if hasattr(scientist, "rekord_w_bpp"): autor = scientist.rekord_w_bpp - IgnoredAuthor.objects.create( + IgnoredScientist.objects.create( scientist=scientist, autor=autor, reason=reason, created_by=request.user ) messages.success( @@ -543,8 +543,8 @@ def reset_ignored_authors(request): """ Remove all ignored author markings. """ - count = IgnoredAuthor.objects.count() - IgnoredAuthor.objects.all().delete() + count = IgnoredScientist.objects.count() + IgnoredScientist.objects.all().delete() messages.success(request, f"Zresetowano {count} ignorowanych autorów.") return redirect("deduplikator_autorow:duplicate_authors") From 49d2c80d22583920a97a76744209beff010bd667 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Fri, 1 May 2026 09:44:33 +0200 Subject: [PATCH 02/25] =?UTF-8?q?feat(deduplikator):=20nowy=20model=20Igno?= =?UTF-8?q?redAuthor=20(FK=E2=86=92Autor)=20dla=20trybu=20general?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/deduplikator_autorow/admin.py | 37 +++++ .../migrations/0010_add_ignored_author.py | 142 ++++++++++++++++++ src/deduplikator_autorow/models.py | 33 ++++ .../tests/test_models_ignored.py | 37 +++++ 4 files changed, 249 insertions(+) create mode 100644 src/deduplikator_autorow/migrations/0010_add_ignored_author.py create mode 100644 src/deduplikator_autorow/tests/test_models_ignored.py diff --git a/src/deduplikator_autorow/admin.py b/src/deduplikator_autorow/admin.py index 0e200327e..407f2e8a2 100644 --- a/src/deduplikator_autorow/admin.py +++ b/src/deduplikator_autorow/admin.py @@ -9,6 +9,7 @@ from .models import ( DuplicateCandidate, DuplicateScanRun, + IgnoredAuthor, IgnoredScientist, LogScalania, NotADuplicate, @@ -133,6 +134,42 @@ def save_model(self, request, obj, form, change): super().save_model(request, obj, form, change) +@admin.register(IgnoredAuthor) +class IgnoredAuthorAdmin(DynamicAdminFilterMixin, admin.ModelAdmin): + list_display = [ + "get_autor_display", + "reason", + "created_by", + "created_on", + ] + + list_filter = ["created_on", "created_by"] + + search_fields = [ + "autor__nazwisko", + "autor__imiona", + "reason", + "created_by__username", + ] + + readonly_fields = ["created_on"] + date_hierarchy = "created_on" + ordering = ["-created_on"] + + def get_autor_display(self, obj): + if obj.autor: + url = reverse("admin:bpp_autor_change", args=[obj.autor.pk]) + return mark_safe(f'{obj.autor}') + return "-" + + get_autor_display.short_description = "Autor (BPP)" + + def save_model(self, request, obj, form, change): + if not change: + obj.created_by = request.user + super().save_model(request, obj, form, change) + + @admin.register(LogScalania) class LogScalaniaAdmin(DynamicAdminFilterMixin, admin.ModelAdmin): list_display = [ diff --git a/src/deduplikator_autorow/migrations/0010_add_ignored_author.py b/src/deduplikator_autorow/migrations/0010_add_ignored_author.py new file mode 100644 index 000000000..b9f55e6dc --- /dev/null +++ b/src/deduplikator_autorow/migrations/0010_add_ignored_author.py @@ -0,0 +1,142 @@ +import django.db.models.deletion +import django.utils.timezone +from django.conf import settings +from django.db import migrations, models + + +def rename_leftover_ignoredauthor_indexes(apps, schema_editor): + """Rename indexes that PostgreSQL kept after RenameModel in 0009. + + Migration 0009 renamed the IgnoredAuthor model to IgnoredScientist, which + in PostgreSQL renames the table but keeps existing index names. Those + `deduplikator_autorow_ignoredauthor_*` index names would collide with + auto-generated names for the new IgnoredAuthor model created here. + + We rename them to match the new (IgnoredScientist) table to avoid the + collision and keep names consistent with the actual table. SQL is + idempotent (uses IF EXISTS) so it works against fresh DBs too. + """ + renames = [ + ( + "deduplikator_autorow_ignoredauthor_autor_id_5e237500", + "deduplikator_autorow_ignoredsci_autor_id_5e237500", + ), + ( + "deduplikator_autorow_ignoredauthor_created_by_id_3d0a197e", + "deduplikator_autorow_ignoredsci_created_by_id_3d0a197e", + ), + ( + "deduplikator_autorow_ignoredauthor_scientist_id_ae6083d3_like", + "deduplikator_autorow_ignoredsci_scientist_id_ae6083d3_like", + ), + ( + "deduplikator_autorow_ignoredauthor_pkey", + "deduplikator_autorow_ignoredscientist_pkey", + ), + ( + "deduplikator_autorow_ignoredauthor_scientist_id_key", + "deduplikator_autorow_ignoredscientist_scientist_id_key", + ), + ] + with schema_editor.connection.cursor() as cursor: + for old_name, new_name in renames: + cursor.execute( + f'ALTER INDEX IF EXISTS "{old_name}" RENAME TO "{new_name}"' + ) + + +def reverse_rename_leftover_ignoredauthor_indexes(apps, schema_editor): + renames = [ + ( + "deduplikator_autorow_ignoredsci_autor_id_5e237500", + "deduplikator_autorow_ignoredauthor_autor_id_5e237500", + ), + ( + "deduplikator_autorow_ignoredsci_created_by_id_3d0a197e", + "deduplikator_autorow_ignoredauthor_created_by_id_3d0a197e", + ), + ( + "deduplikator_autorow_ignoredsci_scientist_id_ae6083d3_like", + "deduplikator_autorow_ignoredauthor_scientist_id_ae6083d3_like", + ), + ( + "deduplikator_autorow_ignoredscientist_pkey", + "deduplikator_autorow_ignoredauthor_pkey", + ), + ( + "deduplikator_autorow_ignoredscientist_scientist_id_key", + "deduplikator_autorow_ignoredauthor_scientist_id_key", + ), + ] + with schema_editor.connection.cursor() as cursor: + for old_name, new_name in renames: + cursor.execute( + f'ALTER INDEX IF EXISTS "{old_name}" RENAME TO "{new_name}"' + ) + + +class Migration(migrations.Migration): + + dependencies = [ + ("bpp", "0413_bppuser_autor_onetoone"), + ("deduplikator_autorow", "0009_rename_ignoredauthor_ignoredscientist"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.RunPython( + rename_leftover_ignoredauthor_indexes, + reverse_rename_leftover_ignoredauthor_indexes, + ), + migrations.CreateModel( + name="IgnoredAuthor", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "reason", + models.CharField( + blank=True, + max_length=500, + verbose_name="Powód ignorowania", + ), + ), + ( + "created_on", + models.DateTimeField( + default=django.utils.timezone.now, + verbose_name="Data utworzenia", + ), + ), + ( + "autor", + models.OneToOneField( + help_text="Autor BPP do ignorowania w deduplikacji ogólnej", + on_delete=django.db.models.deletion.CASCADE, + to="bpp.autor", + verbose_name="Autor (BPP)", + ), + ), + ( + "created_by", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + verbose_name="Utworzył", + ), + ), + ], + options={ + "verbose_name": "Ignorowany autor (BPP)", + "verbose_name_plural": "Ignorowani autorzy (BPP)", + "ordering": ["-created_on"], + }, + ), + ] diff --git a/src/deduplikator_autorow/models.py b/src/deduplikator_autorow/models.py index c0ccffc37..7b1bfa6dd 100644 --- a/src/deduplikator_autorow/models.py +++ b/src/deduplikator_autorow/models.py @@ -76,6 +76,39 @@ def __str__(self): return f"Ignorowany: Scientist #{self.scientist.pk}" +class IgnoredAuthor(models.Model): + """BPP authors (without PBN-Scientist link) that should be ignored in deduplication.""" + + autor = models.OneToOneField( + "bpp.Autor", + on_delete=models.CASCADE, + db_index=True, + verbose_name="Autor (BPP)", + help_text="Autor BPP do ignorowania w deduplikacji ogólnej", + ) + + reason = models.CharField( + max_length=500, + blank=True, + verbose_name="Powód ignorowania", + ) + + created_on = models.DateTimeField("Data utworzenia", default=timezone.now) + created_by = models.ForeignKey( + BppUser, + on_delete=models.CASCADE, + verbose_name="Utworzył", + ) + + class Meta: + verbose_name = "Ignorowany autor (BPP)" + verbose_name_plural = "Ignorowani autorzy (BPP)" + ordering = ["-created_on"] + + def __str__(self): + return f"Ignorowany autor: {self.autor}" + + class LogScalania(models.Model): """Log of author merge operations with detailed tracking""" diff --git a/src/deduplikator_autorow/tests/test_models_ignored.py b/src/deduplikator_autorow/tests/test_models_ignored.py new file mode 100644 index 000000000..90f86df6f --- /dev/null +++ b/src/deduplikator_autorow/tests/test_models_ignored.py @@ -0,0 +1,37 @@ +"""Testy modelu IgnoredAuthor (general) i IgnoredScientist (PBN).""" + +import pytest +from model_bakery import baker + +from deduplikator_autorow.models import IgnoredAuthor, IgnoredScientist + + +@pytest.mark.django_db +def test_ignored_scientist_can_be_created(): + scientist = baker.make("pbn_api.Scientist") + user = baker.make("bpp.BppUser") + obj = IgnoredScientist.objects.create(scientist=scientist, created_by=user) + assert obj.pk is not None + assert obj.scientist == scientist + + +@pytest.mark.django_db +def test_ignored_author_can_be_created(): + autor = baker.make("bpp.Autor") + user = baker.make("bpp.BppUser") + obj = IgnoredAuthor.objects.create(autor=autor, created_by=user, reason="test") + assert obj.pk is not None + assert obj.autor == autor + assert obj.reason == "test" + + +@pytest.mark.django_db +def test_ignored_author_one_to_one_constraint(): + """Próba podwójnego dodania tego samego autora rzuca IntegrityError.""" + from django.db import IntegrityError + + autor = baker.make("bpp.Autor") + user = baker.make("bpp.BppUser") + IgnoredAuthor.objects.create(autor=autor, created_by=user) + with pytest.raises(IntegrityError): + IgnoredAuthor.objects.create(autor=autor, created_by=user) From 698984d4a1048abc53020fb6107ad8bcdf2f6ecb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Fri, 1 May 2026 09:56:05 +0200 Subject: [PATCH 03/25] feat(deduplikator): pola phase, scan_mode, status PARTIAL_COMPLETED, constraint --- .../0011_scan_mode_phase_partial.py | 71 +++++++++++ src/deduplikator_autorow/models.py | 24 +++- .../tests/test_models_scan_fields.py | 113 ++++++++++++++++++ 3 files changed, 206 insertions(+), 2 deletions(-) create mode 100644 src/deduplikator_autorow/migrations/0011_scan_mode_phase_partial.py create mode 100644 src/deduplikator_autorow/tests/test_models_scan_fields.py diff --git a/src/deduplikator_autorow/migrations/0011_scan_mode_phase_partial.py b/src/deduplikator_autorow/migrations/0011_scan_mode_phase_partial.py new file mode 100644 index 000000000..9e56f826e --- /dev/null +++ b/src/deduplikator_autorow/migrations/0011_scan_mode_phase_partial.py @@ -0,0 +1,71 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("deduplikator_autorow", "0010_add_ignored_author"), + ] + + operations = [ + migrations.AlterField( + model_name="duplicatescanrun", + name="status", + field=models.CharField( + choices=[ + ("pending", "Oczekuje"), + ("running", "W trakcie"), + ("completed", "Zakończone"), + ( + "partial_completed", + "Częściowo zakończone (faza PBN OK, general anulowana)", + ), + ("cancelled", "Anulowane"), + ("failed", "Błąd"), + ], + db_index=True, + default="pending", + max_length=20, + verbose_name="Status", + ), + ), + migrations.AddField( + model_name="duplicatescanrun", + name="phase", + field=models.CharField( + blank=True, + choices=[("pbn", "Faza PBN"), ("general", "Faza ogólna")], + max_length=20, + verbose_name="Aktualna faza", + ), + ), + migrations.AddField( + model_name="duplicatecandidate", + name="scan_mode", + field=models.CharField( + choices=[("pbn", "PBN"), ("general", "Ogólny")], + db_index=True, + default="pbn", + max_length=20, + verbose_name="Tryb skanowania", + ), + ), + migrations.RemoveConstraint( + model_name="duplicatecandidate", + name="unique_scan_main_duplicate", + ), + migrations.AddIndex( + model_name="duplicatecandidate", + index=models.Index( + fields=["scan_run", "scan_mode", "status"], + name="deduplikato_scan_ru_78ad22_idx", + ), + ), + migrations.AddConstraint( + model_name="duplicatecandidate", + constraint=models.UniqueConstraint( + fields=("scan_run", "scan_mode", "main_autor", "duplicate_autor"), + name="unique_scan_mode_main_duplicate", + ), + ), + ] diff --git a/src/deduplikator_autorow/models.py b/src/deduplikator_autorow/models.py index 7b1bfa6dd..33c299126 100644 --- a/src/deduplikator_autorow/models.py +++ b/src/deduplikator_autorow/models.py @@ -259,6 +259,10 @@ class Status(models.TextChoices): PENDING = "pending", "Oczekuje" RUNNING = "running", "W trakcie" COMPLETED = "completed", "Zakończone" + PARTIAL_COMPLETED = ( + "partial_completed", + "Częściowo zakończone (faza PBN OK, general anulowana)", + ) CANCELLED = "cancelled", "Anulowane" FAILED = "failed", "Błąd" @@ -307,6 +311,13 @@ class Status(models.TextChoices): blank=True, ) + phase = models.CharField( + "Aktualna faza", + max_length=20, + blank=True, + choices=[("pbn", "Faza PBN"), ("general", "Faza ogólna")], + ) + class Meta: verbose_name = "Skanowanie duplikatów" verbose_name_plural = "Skanowania duplikatów" @@ -385,6 +396,14 @@ class Status(models.TextChoices): help_text="Priorytet wyświetlania: 100=prace 2022-2025 z dyscyplinami, 50=prace 2022-2025, 0=inne", ) + scan_mode = models.CharField( + "Tryb skanowania", + max_length=20, + choices=[("pbn", "PBN"), ("general", "Ogólny")], + default="pbn", + db_index=True, + ) + # Status tracking status = models.CharField( "Status", @@ -435,11 +454,12 @@ class Meta: models.Index(fields=["scan_run", "status"]), models.Index(fields=["main_autor", "status"]), models.Index(fields=["priority", "confidence_score"]), + models.Index(fields=["scan_run", "scan_mode", "status"]), ] constraints = [ models.UniqueConstraint( - fields=["scan_run", "main_autor", "duplicate_autor"], - name="unique_scan_main_duplicate", + fields=["scan_run", "scan_mode", "main_autor", "duplicate_autor"], + name="unique_scan_mode_main_duplicate", ), ] diff --git a/src/deduplikator_autorow/tests/test_models_scan_fields.py b/src/deduplikator_autorow/tests/test_models_scan_fields.py new file mode 100644 index 000000000..5e19fa249 --- /dev/null +++ b/src/deduplikator_autorow/tests/test_models_scan_fields.py @@ -0,0 +1,113 @@ +"""Testy nowych pól: phase, scan_mode, PARTIAL_COMPLETED status.""" + +import pytest +from model_bakery import baker + +from deduplikator_autorow.models import DuplicateCandidate, DuplicateScanRun + + +@pytest.mark.django_db +def test_scan_run_phase_field_default_blank(): + scan = DuplicateScanRun.objects.create() + assert scan.phase == "" + + +@pytest.mark.django_db +def test_scan_run_phase_field_can_be_set(): + scan = DuplicateScanRun.objects.create(phase="general") + scan.refresh_from_db() + assert scan.phase == "general" + + +@pytest.mark.django_db +def test_scan_run_partial_completed_status(): + scan = DuplicateScanRun.objects.create( + status=DuplicateScanRun.Status.PARTIAL_COMPLETED + ) + scan.refresh_from_db() + assert scan.status == "partial_completed" + assert scan.get_status_display() == ( + "Częściowo zakończone (faza PBN OK, general anulowana)" + ) + + +@pytest.mark.django_db +def test_candidate_scan_mode_default_pbn(): + scan = DuplicateScanRun.objects.create() + autor1 = baker.make("bpp.Autor") + autor2 = baker.make("bpp.Autor") + cand = DuplicateCandidate.objects.create( + scan_run=scan, + main_autor=autor1, + duplicate_autor=autor2, + confidence_score=80, + confidence_percent=0.5, + main_autor_name="Test Main", + duplicate_autor_name="Test Dup", + ) + cand.refresh_from_db() + assert cand.scan_mode == "pbn" + + +@pytest.mark.django_db +def test_candidate_scan_mode_general(): + scan = DuplicateScanRun.objects.create() + autor1 = baker.make("bpp.Autor") + autor2 = baker.make("bpp.Autor") + cand = DuplicateCandidate.objects.create( + scan_run=scan, + main_autor=autor1, + duplicate_autor=autor2, + confidence_score=80, + confidence_percent=0.5, + main_autor_name="Test Main", + duplicate_autor_name="Test Dup", + scan_mode="general", + ) + cand.refresh_from_db() + assert cand.scan_mode == "general" + + +@pytest.mark.django_db +def test_candidate_unique_constraint_includes_scan_mode(): + """Ta sama para (main, dup) może istnieć w obu trybach, ale nie dwa razy w jednym.""" + from django.db import IntegrityError, transaction + + scan = DuplicateScanRun.objects.create() + autor1 = baker.make("bpp.Autor") + autor2 = baker.make("bpp.Autor") + + DuplicateCandidate.objects.create( + scan_run=scan, + main_autor=autor1, + duplicate_autor=autor2, + confidence_score=80, + confidence_percent=0.5, + main_autor_name="A", + duplicate_autor_name="B", + scan_mode="pbn", + ) + # Ta sama para w trybie general — OK + DuplicateCandidate.objects.create( + scan_run=scan, + main_autor=autor1, + duplicate_autor=autor2, + confidence_score=80, + confidence_percent=0.5, + main_autor_name="A", + duplicate_autor_name="B", + scan_mode="general", + ) + # Drugi raz w trybie pbn — IntegrityError + with pytest.raises(IntegrityError): + with transaction.atomic(): + DuplicateCandidate.objects.create( + scan_run=scan, + main_autor=autor1, + duplicate_autor=autor2, + confidence_score=80, + confidence_percent=0.5, + main_autor_name="A", + duplicate_autor_name="B", + scan_mode="pbn", + ) From f3b377e19b71cf8f19dad7a1955ee09d4bb13de3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Fri, 1 May 2026 09:59:35 +0200 Subject: [PATCH 04/25] =?UTF-8?q?feat(deduplikator):=20utils.cluster=20?= =?UTF-8?q?=E2=80=94=20union-find=20dla=20klastr=C3=B3w=20autor=C3=B3w?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../tests/test_cluster.py | 39 ++++++++++++++++++ src/deduplikator_autorow/utils/cluster.py | 41 +++++++++++++++++++ 2 files changed, 80 insertions(+) create mode 100644 src/deduplikator_autorow/tests/test_cluster.py create mode 100644 src/deduplikator_autorow/utils/cluster.py diff --git a/src/deduplikator_autorow/tests/test_cluster.py b/src/deduplikator_autorow/tests/test_cluster.py new file mode 100644 index 000000000..42b214e35 --- /dev/null +++ b/src/deduplikator_autorow/tests/test_cluster.py @@ -0,0 +1,39 @@ +"""Testy union-find (connected components).""" + +from deduplikator_autorow.utils.cluster import find_clusters + + +def test_two_disjoint_pairs(): + pairs = [(1, 2), (3, 4)] + clusters = sorted(find_clusters(pairs), key=min) + assert clusters == [{1, 2}, {3, 4}] + + +def test_transitive_cluster(): + """A~B and B~C → cluster {A, B, C}.""" + pairs = [(1, 2), (2, 3)] + clusters = list(find_clusters(pairs)) + assert clusters == [{1, 2, 3}] + + +def test_single_pair(): + pairs = [(7, 8)] + clusters = list(find_clusters(pairs)) + assert clusters == [{7, 8}] + + +def test_no_pairs(): + assert list(find_clusters([])) == [] + + +def test_isolated_nodes_with_pairs(): + """Tylko węzły mające połączenia trafiają do klastrów.""" + pairs = [(1, 2), (5, 6), (2, 3)] + clusters = sorted(find_clusters(pairs), key=min) + assert clusters == [{1, 2, 3}, {5, 6}] + + +def test_duplicate_pairs_are_idempotent(): + pairs = [(1, 2), (1, 2), (2, 1)] + clusters = list(find_clusters(pairs)) + assert clusters == [{1, 2}] diff --git a/src/deduplikator_autorow/utils/cluster.py b/src/deduplikator_autorow/utils/cluster.py new file mode 100644 index 000000000..219eb8aba --- /dev/null +++ b/src/deduplikator_autorow/utils/cluster.py @@ -0,0 +1,41 @@ +"""Union-find (connected components) dla par autorów. + +Dla zbioru par (a, b) zwraca spójne komponenty grafu. +""" + + +def find_clusters(pairs): + """Zwraca listę zbiorów (klastrów) z par. + + Args: + pairs: iterable krotek (pk_a, pk_b). + + Returns: + list[set[int]]: lista klastrów (każdy klaster to set PKów). + """ + parent: dict = {} + + def find(x): + while parent[x] != x: + parent[x] = parent[parent[x]] # path compression + x = parent[x] + return x + + def union(a, b): + ra, rb = find(a), find(b) + if ra != rb: + parent[ra] = rb + + for a, b in pairs: + if a not in parent: + parent[a] = a + if b not in parent: + parent[b] = b + union(a, b) + + clusters_by_root: dict = {} + for node in parent: + root = find(node) + clusters_by_root.setdefault(root, set()).add(node) + + return list(clusters_by_root.values()) From b1f61708713de189da0ad017393fcbfc3b00377d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Fri, 1 May 2026 10:01:35 +0200 Subject: [PATCH 05/25] =?UTF-8?q?feat(deduplikator):=20utils.main=5Fselect?= =?UTF-8?q?ion=20=E2=80=94=20hierarchia=20wyboru=20g=C5=82=C3=B3wnego?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../tests/test_main_selection.py | 75 +++++++++++++++++++ .../utils/main_selection.py | 38 ++++++++++ 2 files changed, 113 insertions(+) create mode 100644 src/deduplikator_autorow/tests/test_main_selection.py create mode 100644 src/deduplikator_autorow/utils/main_selection.py diff --git a/src/deduplikator_autorow/tests/test_main_selection.py b/src/deduplikator_autorow/tests/test_main_selection.py new file mode 100644 index 000000000..6dbeea8d2 --- /dev/null +++ b/src/deduplikator_autorow/tests/test_main_selection.py @@ -0,0 +1,75 @@ +"""Testy hierarchii wyboru głównego rekordu (hierarchia B).""" + +from deduplikator_autorow.utils.main_selection import pick_main_pk + + +def _meta(**kwargs): + """Helper — minimalny wpis meta.""" + base = { + "ma_orcid": False, + "ma_pbn_uid": False, + "ma_tytul": False, + "ma_dyscypline": False, + "publikacje_count": 0, + "max_rok": 0, + } + base.update(kwargs) + return base + + +def test_orcid_wins_over_everything(): + metas = { + 1: _meta(ma_orcid=False, publikacje_count=100, max_rok=2025), + 2: _meta(ma_orcid=True, publikacje_count=1, max_rok=2000), + } + cluster = {1, 2} + assert pick_main_pk(cluster, metas) == 2 + + +def test_pbn_uid_wins_when_orcid_tied(): + metas = { + 1: _meta(ma_orcid=True, ma_pbn_uid=False), + 2: _meta(ma_orcid=True, ma_pbn_uid=True), + } + assert pick_main_pk({1, 2}, metas) == 2 + + +def test_tytul_wins_when_above_tied(): + metas = { + 1: _meta(ma_orcid=True, ma_pbn_uid=True, ma_tytul=False), + 2: _meta(ma_orcid=True, ma_pbn_uid=True, ma_tytul=True), + } + assert pick_main_pk({1, 2}, metas) == 2 + + +def test_dyscyplina_wins_when_above_tied(): + metas = { + 1: _meta(ma_orcid=True, ma_pbn_uid=True, ma_tytul=True, ma_dyscypline=False), + 2: _meta(ma_orcid=True, ma_pbn_uid=True, ma_tytul=True, ma_dyscypline=True), + } + assert pick_main_pk({1, 2}, metas) == 2 + + +def test_publikacje_count_wins_when_above_tied(): + metas = { + 1: _meta(publikacje_count=5), + 2: _meta(publikacje_count=10), + } + assert pick_main_pk({1, 2}, metas) == 2 + + +def test_max_rok_wins_when_publikacje_tied(): + metas = { + 1: _meta(publikacje_count=5, max_rok=2020), + 2: _meta(publikacje_count=5, max_rok=2025), + } + assert pick_main_pk({1, 2}, metas) == 2 + + +def test_pk_lowest_wins_when_all_tied(): + metas = { + 77: _meta(), + 12: _meta(), + 99: _meta(), + } + assert pick_main_pk({77, 12, 99}, metas) == 12 diff --git a/src/deduplikator_autorow/utils/main_selection.py b/src/deduplikator_autorow/utils/main_selection.py new file mode 100644 index 000000000..ef5fc6ff9 --- /dev/null +++ b/src/deduplikator_autorow/utils/main_selection.py @@ -0,0 +1,38 @@ +"""Wybór głównego rekordu (main) w klastrze duplikatów. + +Hierarchia (kolejne kryteria odpalają tylko przy remisie): +1. ma_orcid (DESC) +2. ma_pbn_uid (DESC) +3. ma_tytul (DESC) +4. ma_dyscypline (DESC) +5. publikacje_count (DESC) +6. max_rok (DESC) +7. pk (ASC) +""" + + +def _selection_key(pk: int, meta: dict) -> tuple: + """Klucz sortowania — niższe wartości = lepszy kandydat na main.""" + return ( + not meta["ma_orcid"], + not meta["ma_pbn_uid"], + not meta["ma_tytul"], + not meta["ma_dyscypline"], + -meta["publikacje_count"], + -(meta["max_rok"] or 0), + pk, + ) + + +def pick_main_pk(cluster: set[int], metas: dict[int, dict]) -> int: + """Z klastra (set PKów) wybiera PK głównego rekordu. + + Args: + cluster: set PKów członków klastra. + metas: {pk -> meta dict z polami ma_orcid, ma_pbn_uid, ma_tytul, + ma_dyscypline, publikacje_count, max_rok}. + + Returns: + PK rekordu wybranego jako main. + """ + return min(cluster, key=lambda pk: _selection_key(pk, metas[pk])) From 552865f32dfe1bf64c07a46c5a5ea8d9cd55483d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Fri, 1 May 2026 10:07:05 +0200 Subject: [PATCH 06/25] =?UTF-8?q?feat(deduplikator):=20utils.meta=20?= =?UTF-8?q?=E2=80=94=20pre-load=20wszystkich=20autor=C3=B3w=20do=20pami?= =?UTF-8?q?=C4=99ci?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/deduplikator_autorow/tests/test_meta.py | 86 +++++++++++ src/deduplikator_autorow/utils/meta.py | 150 ++++++++++++++++++++ 2 files changed, 236 insertions(+) create mode 100644 src/deduplikator_autorow/tests/test_meta.py create mode 100644 src/deduplikator_autorow/utils/meta.py diff --git a/src/deduplikator_autorow/tests/test_meta.py b/src/deduplikator_autorow/tests/test_meta.py new file mode 100644 index 000000000..f93602b3f --- /dev/null +++ b/src/deduplikator_autorow/tests/test_meta.py @@ -0,0 +1,86 @@ +"""Testy budowniczego meta-cache dla autorów.""" + +import pytest +from django.db import connection +from django.test.utils import CaptureQueriesContext +from model_bakery import baker + +from deduplikator_autorow.utils.meta import build_autor_meta, build_buckets + + +@pytest.mark.django_db +def test_meta_includes_basic_fields(): + autor = baker.make( + "bpp.Autor", + nazwisko="Kowalski", + imiona="Jan", + orcid="0000-0001-2345-6789", + ) + meta = build_autor_meta() + assert autor.pk in meta + m = meta[autor.pk] + assert m["nazwisko_norm"] == "kowalski" + assert m["imiona_norm"] == ["jan"] + assert m["ma_orcid"] is True + assert m["orcid_value"] == "0000-0001-2345-6789" + assert m["ma_pbn_uid"] is False + assert m["ma_tytul"] is False + assert m["publikacje_count"] == 0 + assert m["max_rok"] == 0 + assert m["lata_publikacji"] == set() + + +@pytest.mark.django_db +def test_meta_compound_lastname_parts(): + autor = baker.make("bpp.Autor", nazwisko="Gal-Cisoń", imiona="Anna") + meta = build_autor_meta() + parts = meta[autor.pk]["nazwisko_parts"] + assert sorted(parts) == ["cisoń", "gal"] + + +@pytest.mark.django_db +def test_meta_ma_osoba_z_instytucji_true(): + # Scientist nie ma pola "rekord_w_bpp" — to cached_property po stronie + # Scientist; związek jest definiowany przez Autor.pbn_uid → Scientist. + scientist = baker.make("pbn_api.Scientist") + autor = baker.make("bpp.Autor", nazwisko="Xtest", pbn_uid=scientist) + baker.make("pbn_api.OsobaZInstytucji", personId=scientist) + + meta = build_autor_meta() + assert meta[autor.pk]["ma_osoba_z_instytucji"] is True + + +@pytest.mark.django_db +def test_meta_constant_query_count(): + """Sanity: dodanie autorów nie zwiększa liczby zapytań (no N+1).""" + baker.make("bpp.Autor", _quantity=5, nazwisko="A") + with CaptureQueriesContext(connection) as ctx_small: + build_autor_meta() + n_small = len(ctx_small.captured_queries) + + baker.make("bpp.Autor", _quantity=20, nazwisko="B") + with CaptureQueriesContext(connection) as ctx_big: + build_autor_meta() + n_big = len(ctx_big.captured_queries) + + assert n_small == n_big, ( + f"N+1 detected: small={n_small} queries, big={n_big} queries" + ) + + +@pytest.mark.django_db +def test_buckets_includes_lastname_and_parts(): + a1 = baker.make("bpp.Autor", nazwisko="Kowalski") + a2 = baker.make("bpp.Autor", nazwisko="Gal-Cisoń") + meta = build_autor_meta() + buckets = build_buckets(meta) + + assert "kowalski" in buckets + assert a1.pk in buckets["kowalski"] + assert "gal" in buckets + assert "cisoń" in buckets + assert "gal-cisoń" in buckets + # reversed compound: + assert "cisoń-gal" in buckets + assert a2.pk in buckets["gal-cisoń"] + assert a2.pk in buckets["cisoń-gal"] diff --git a/src/deduplikator_autorow/utils/meta.py b/src/deduplikator_autorow/utils/meta.py new file mode 100644 index 000000000..fd2b0e824 --- /dev/null +++ b/src/deduplikator_autorow/utils/meta.py @@ -0,0 +1,150 @@ +"""Budowniczy meta-cache dla wszystkich autorów BPP. + +Pre-loaduje wszystkie metadane autorów potrzebne do fazy ``general`` +deduplikatora w stałej liczbie zapytań SQL — niezależnie od N. + +Agregaty publikacji liczone są bezpośrednio na tabelach źródłowych +(``Wydawnictwo_Ciagle_Autor``, ``Wydawnictwo_Zwarte_Autor``, +``Patent_Autor``), żeby działać niezależnie od stanu materializowanych +widoków (``bpp_rekord_mat`` / ``bpp_autorzy_mat``) — które w testach +mogą nie być odświeżone po ``baker.make``. +""" + +from collections import defaultdict + +from django.contrib.postgres.aggregates import ArrayAgg +from django.db.models import Count, Max + +from bpp.models import ( + Autor, + Autor_Dyscyplina, + Patent_Autor, + Wydawnictwo_Ciagle_Autor, + Wydawnictwo_Zwarte_Autor, +) +from pbn_api.models import OsobaZInstytucji + + +def _normalize(s: str | None) -> str: + return (s or "").strip().lower() + + +def _split_compound(nazwisko: str | None) -> list[str]: + if not nazwisko: + return [] + return [_normalize(p) for p in nazwisko.split("-") if p.strip()] + + +def _aggregate_publications(model, autorzy_meta: dict[int, dict]) -> None: + """Doliczy do meta agregaty z jednej tabeli ``*_Autor``. + + Wykonuje DOKŁADNIE jedno zapytanie z ``GROUP BY autor_id``. + """ + rows = ( + model.objects.values("autor_id") + .annotate( + cnt=Count("id"), + max_rok=Max("rekord__rok"), + lata=ArrayAgg("rekord__rok", distinct=True), + ) + .filter(autor_id__isnull=False) + ) + for row in rows: + pk = row["autor_id"] + m = autorzy_meta.get(pk) + if m is None: + continue + m["publikacje_count"] += row["cnt"] or 0 + rok_max = row["max_rok"] or 0 + if rok_max > m["max_rok"]: + m["max_rok"] = rok_max + for r in row["lata"] or []: + if r: + m["lata_publikacji"].add(r) + + +def build_autor_meta() -> dict[int, dict]: + """Buduje słownik ``{autor_pk -> meta}`` w stałej liczbie zapytań SQL. + + Zapytania: + + 1. ``Autor.objects.only(...)`` — pobranie wszystkich autorów. + 2. Agregat publikacji z ``Wydawnictwo_Ciagle_Autor`` (GROUP BY). + 3. Agregat publikacji z ``Wydawnictwo_Zwarte_Autor`` (GROUP BY). + 4. Agregat publikacji z ``Patent_Autor`` (GROUP BY). + 5. ``Autor_Dyscyplina`` — DISTINCT autor_id. + 6. ``OsobaZInstytucji`` — wszystkie ``personId_id``. + + Łącznie 6 zapytań, niezależnie od liczby autorów. + """ + autorzy_meta: dict[int, dict] = {} + autor_qs = Autor.objects.only( + "pk", "nazwisko", "imiona", "orcid", "pbn_uid_id", "tytul_id" + ) + for a in autor_qs.iterator(): + autorzy_meta[a.pk] = { + "obj": a, + "nazwisko_norm": _normalize(a.nazwisko), + "nazwisko_parts": _split_compound(a.nazwisko), + "imiona_norm": [_normalize(i) for i in (a.imiona or "").split() if i], + "ma_orcid": bool(a.orcid), + "orcid_value": a.orcid or None, + "ma_pbn_uid": bool(a.pbn_uid_id), + "ma_tytul": bool(a.tytul_id), + "tytul_id": a.tytul_id, + "ma_osoba_z_instytucji": False, + "ma_dyscypline": False, + "publikacje_count": 0, + "lata_publikacji": set(), + "max_rok": 0, + } + + # Agregaty publikacji — po jednym zapytaniu na typ rekordu. + for model in ( + Wydawnictwo_Ciagle_Autor, + Wydawnictwo_Zwarte_Autor, + Patent_Autor, + ): + _aggregate_publications(model, autorzy_meta) + + # Dyscypliny — jedno DISTINCT. + for pk in Autor_Dyscyplina.objects.values_list("autor_id", flat=True).distinct(): + m = autorzy_meta.get(pk) + if m is not None: + m["ma_dyscypline"] = True + + # OsobaZInstytucji — match po Autor.pbn_uid_id == Scientist.pk + # (Scientist jest OneToOne z OsobaZInstytucji jako personId). + osoba_scientist_ids = set( + OsobaZInstytucji.objects.values_list("personId_id", flat=True) + ) + for m in autorzy_meta.values(): + pbn_uid_id = m["obj"].pbn_uid_id + if pbn_uid_id and pbn_uid_id in osoba_scientist_ids: + m["ma_osoba_z_instytucji"] = True + + return autorzy_meta + + +def build_buckets(meta: dict[int, dict]) -> dict[str, list[int]]: + """Buckety ``{nazwisko_norm -> [pk1, pk2, ...]}`` dla pair-generation. + + Autor trafia do bucketu pod swoim znormalizowanym nazwiskiem, + pod każdym członem nazwiska złożonego (split na ``-``) oraz pod + odwróconym nazwiskiem złożonym (np. ``Gal-Cisoń`` → ``cisoń-gal``). + """ + buckets: dict[str, list[int]] = defaultdict(list) + for pk, m in meta.items(): + nazwisko_norm = m["nazwisko_norm"] + if not nazwisko_norm: + continue + buckets[nazwisko_norm].append(pk) + parts = m["nazwisko_parts"] + for part in parts: + if len(part) > 2 and part != nazwisko_norm: + buckets[part].append(pk) + if len(parts) == 2: + reversed_name = "-".join(reversed(parts)) + if reversed_name != nazwisko_norm: + buckets[reversed_name].append(pk) + return dict(buckets) From 8e5710c2cb48bb5a0897062794ecbb43fc0d6620 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Fri, 1 May 2026 10:11:23 +0200 Subject: [PATCH 07/25] =?UTF-8?q?feat(deduplikator):=20utils.analysis=5Fme?= =?UTF-8?q?ta=20=E2=80=94=20scoring=20par=20bez=20SQL?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../tests/test_analysis_meta.py | 83 ++++++++++++ .../utils/analysis_meta.py | 119 ++++++++++++++++++ 2 files changed, 202 insertions(+) create mode 100644 src/deduplikator_autorow/tests/test_analysis_meta.py create mode 100644 src/deduplikator_autorow/utils/analysis_meta.py diff --git a/src/deduplikator_autorow/tests/test_analysis_meta.py b/src/deduplikator_autorow/tests/test_analysis_meta.py new file mode 100644 index 000000000..e436dcdd3 --- /dev/null +++ b/src/deduplikator_autorow/tests/test_analysis_meta.py @@ -0,0 +1,83 @@ +"""Testy analiza_pary_meta — scoring par autorów na bazie meta.""" + +from deduplikator_autorow.utils.analysis_meta import analiza_pary_meta + + +def _meta( + nazwisko="kowalski", + imiona=("jan",), + orcid=None, + pbn_uid=False, + tytul=False, + pubs=0, + max_rok=0, + lata=None, +): + return { + "nazwisko_norm": nazwisko, + "nazwisko_parts": nazwisko.split("-"), + "imiona_norm": list(imiona), + "orcid_value": orcid, + "ma_orcid": bool(orcid), + "ma_pbn_uid": pbn_uid, + "ma_tytul": tytul, + "tytul_id": 1 if tytul else None, + "publikacje_count": pubs, + "max_rok": max_rok, + "lata_publikacji": set(lata or []), + } + + +def test_identyczne_orcid_dodaje_50(): + a = _meta(orcid="0000-0001-2345-6789") + b = _meta(orcid="0000-0001-2345-6789") + score, reasons = analiza_pary_meta(a, b) + assert score >= 50 + assert any("ORCID" in r for r in reasons) + + +def test_rozne_orcid_odejmuje_50(): + # Różne nazwiska/imiona, żeby ORCID był dominującym sygnałem. + a = _meta( + nazwisko="kowalski", + imiona=("jan",), + orcid="0000-0001-1111-1111", + ) + b = _meta( + nazwisko="nowak", + imiona=("piotr",), + orcid="0000-0002-2222-2222", + ) + score, reasons = analiza_pary_meta(a, b) + assert score <= -40 # -50 plus drobne plusy z innych kryteriów + assert any("różny ORCID" in r for r in reasons) + + +def test_identyczne_nazwisko_dodaje_40(): + a = _meta(nazwisko="kowalski") + b = _meta(nazwisko="kowalski") + score, reasons = analiza_pary_meta(a, b) + assert score >= 40 + assert any("nazwisko" in r.lower() for r in reasons) + + +def test_wspolne_lata_publikacji_dodaje_20(): + a = _meta(lata=[2020, 2021, 2022]) + b = _meta(lata=[2021, 2022]) + score, reasons = analiza_pary_meta(a, b) + assert any("wspólne lata" in r.lower() for r in reasons) + + +def test_score_to_int(): + a = _meta() + b = _meta() + score, _ = analiza_pary_meta(a, b) + assert isinstance(score, int) + + +def test_swap_imienia_z_nazwiskiem_dodaje_50(): + """Pełna zamiana imię ↔ nazwisko: A 'kowalski jan', B 'jan kowalski'.""" + a = _meta(nazwisko="kowalski", imiona=("jan",)) + b = _meta(nazwisko="jan", imiona=("kowalski",)) + score, reasons = analiza_pary_meta(a, b) + assert any("zamian" in r.lower() for r in reasons) diff --git a/src/deduplikator_autorow/utils/analysis_meta.py b/src/deduplikator_autorow/utils/analysis_meta.py new file mode 100644 index 000000000..d3639e723 --- /dev/null +++ b/src/deduplikator_autorow/utils/analysis_meta.py @@ -0,0 +1,119 @@ +"""Analiza pary autorów na bazie wyłącznie meta-cache (bez SQL). + +Mirror'uje wagi punktowe z ``utils/analysis.py:analiza_duplikatow`` żeby +zachować spójność scoringu między fazą PBN i general. +Pomija tylko analizę płci (która w wersji DB-owej używa +``Autor.plec`` + heurystyki na imieniu — nie potrzebne w v1 trybu general). +""" + + +def _common_initials(imiona_a: list[str], imiona_b: list[str]) -> int: + initials_a = {x[0] for x in imiona_a if x} + initials_b = {x[0] for x in imiona_b if x} + return len(initials_a & initials_b) + + +def analiza_pary_meta(a: dict, b: dict) -> tuple[int, list[str]]: # noqa: C901 + """Zwraca (score, reasons) dla pary (a, b) na bazie meta-cache.""" + score = 0 + reasons: list[str] = [] + + pubs_b = b["publikacje_count"] + if pubs_b <= 5: + score += 10 + reasons.append(f"mało publikacji ({pubs_b}) - prawdopodobny duplikat") + elif pubs_b <= 10: + score -= 10 + reasons.append(f"średnio publikacji ({pubs_b}) - możliwy duplikat") + else: + score -= 20 + reasons.append(f"wiele publikacji ({pubs_b}) - mało prawdopodobny duplikat") + + if not b["ma_tytul"] and a["ma_tytul"]: + score += 15 + reasons.append("brak tytułu naukowego u kandydata - prawdopodobny duplikat") + elif b["ma_tytul"] and a["ma_tytul"]: + if a.get("tytul_id") == b.get("tytul_id"): + score += 10 + reasons.append("identyczny tytuł naukowy") + else: + score -= 15 + reasons.append("różny tytuł naukowy") + + if not b["ma_orcid"] and a["ma_orcid"]: + score += 15 + reasons.append("brak ORCID u kandydata - prawdopodobny duplikat") + elif b["ma_orcid"] and a["ma_orcid"]: + if a.get("orcid_value") == b.get("orcid_value"): + score += 50 + reasons.append("identyczny ORCID - to ten sam autor") + else: + score -= 50 + reasons.append("różny ORCID - to różni autorzy") + + if a["nazwisko_norm"] and b["nazwisko_norm"]: + if a["nazwisko_norm"] == b["nazwisko_norm"]: + score += 40 + reasons.append("identyczne nazwisko") + elif ( + a["nazwisko_norm"] in b["nazwisko_norm"] + or b["nazwisko_norm"] in a["nazwisko_norm"] + ): + score += 30 + reasons.append("podobne nazwisko (zawieranie)") + + if ( + a["nazwisko_norm"] + and b["nazwisko_norm"] + and a["imiona_norm"] + and b["imiona_norm"] + ): + if (a["nazwisko_norm"] in b["imiona_norm"]) and ( + b["nazwisko_norm"] in a["imiona_norm"] + ): + score += 50 + reasons.append("wykryto pełną zamianę imienia z nazwiskiem") + + common = set(a["imiona_norm"]) & set(b["imiona_norm"]) + if common: + score += 30 * len(common) + reasons.append(f"wspólne imię ({len(common)})") + + similar = 0 + for ia in a["imiona_norm"]: + for ib in b["imiona_norm"]: + if len(ia) >= 3 and len(ib) >= 3 and ia != ib: + if ia.startswith(ib[:3]) or ib.startswith(ia[:3]): + similar += 1 + if similar: + score += 15 * similar + reasons.append(f"podobne imię ({similar})") + + init_count = _common_initials(a["imiona_norm"], b["imiona_norm"]) + if init_count: + score += 5 * init_count + reasons.append(f"pasujące inicjały ({init_count})") + + if not b["imiona_norm"] and a["imiona_norm"]: + score += 10 + reasons.append("brak imion u kandydata") + + common_lata = a["lata_publikacji"] & b["lata_publikacji"] + if common_lata: + score += 20 + reasons.append(f"wspólne lata publikacji: {sorted(common_lata)}") + elif a["lata_publikacji"] and b["lata_publikacji"]: + min_dist = min( + abs(ra - rb) for ra in a["lata_publikacji"] for rb in b["lata_publikacji"] + ) + if min_dist <= 2: + score += 15 + reasons.append(f"bliskie lata publikacji (różnica {min_dist})") + elif min_dist <= 7: + score -= 5 + reasons.append(f"średnia odległość lat publikacji ({min_dist})") + else: + score -= 20 + reasons.append(f"duża odległość lat publikacji ({min_dist})") + + return score, reasons From 97ced1e651753fd813b4b694dd6a56b0c52f7bb5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Fri, 1 May 2026 10:15:58 +0200 Subject: [PATCH 08/25] =?UTF-8?q?feat(deduplikator):=20utils.search=5Fgene?= =?UTF-8?q?ral=20=E2=80=94=20generator=20par=20dla=20trybu=20general?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dodaje in-memory generator par kandydatów oparty o buckety nazwisk: iteruje po bucketach (skipuje > BUCKET_MAX_SIZE=200 z warningiem), generuje pary nieuporządkowane (pk_a < pk_b), deduplikuje symetryczne (autor może trafić do wielu bucketów przez compound nazwisko / reverse), filtruje przez ignored_pks/notadup_pks i emituje tylko pary score >= MIN_CONFIDENCE_TO_STORE (50). Rozszerza analiza_pary_meta o detekcję compound nazwisk po nazwisko_parts: pełna permutacja członów (np. 'Gal-Cisoń' ↔ 'Cisoń-Gal') daje +35, częściowe pokrycie +20 — bez tego sygnał z bucketu reverse-compound nie miał szans przekroczyć progu. --- .../tests/test_search_general.py | 79 +++++++++++++++++++ .../utils/analysis_meta.py | 16 ++++ .../utils/search_general.py | 55 +++++++++++++ 3 files changed, 150 insertions(+) create mode 100644 src/deduplikator_autorow/tests/test_search_general.py create mode 100644 src/deduplikator_autorow/utils/search_general.py diff --git a/src/deduplikator_autorow/tests/test_search_general.py b/src/deduplikator_autorow/tests/test_search_general.py new file mode 100644 index 000000000..88a0cc162 --- /dev/null +++ b/src/deduplikator_autorow/tests/test_search_general.py @@ -0,0 +1,79 @@ +"""Testy generowania par kandydatów w fazie general.""" + +import pytest +from model_bakery import baker + +from deduplikator_autorow.utils.meta import build_autor_meta, build_buckets +from deduplikator_autorow.utils.search_general import ( + BUCKET_MAX_SIZE, + generate_pairs, +) + + +@pytest.mark.django_db +def test_simple_lastname_pair(): + a1 = baker.make("bpp.Autor", nazwisko="Kowalski", imiona="Jan") + a2 = baker.make("bpp.Autor", nazwisko="Kowalski", imiona="Jan") + meta = build_autor_meta() + buckets = build_buckets(meta) + pairs = list(generate_pairs(buckets, meta, ignored_pks=set(), notadup_pks=set())) + pks = {(min(p, q), max(p, q)) for p, q, _, _ in pairs} + assert (min(a1.pk, a2.pk), max(a1.pk, a2.pk)) in pks + + +@pytest.mark.django_db +def test_compound_lastname_pair(): + a1 = baker.make("bpp.Autor", nazwisko="Gal-Cisoń", imiona="Anna") + a2 = baker.make("bpp.Autor", nazwisko="Cisoń-Gal", imiona="Anna") + meta = build_autor_meta() + buckets = build_buckets(meta) + pairs = list(generate_pairs(buckets, meta, ignored_pks=set(), notadup_pks=set())) + pks = {(min(p, q), max(p, q)) for p, q, _, _ in pairs} + assert (min(a1.pk, a2.pk), max(a1.pk, a2.pk)) in pks + + +@pytest.mark.django_db +def test_pair_dedup(): + """Para (a, b) emitowana tylko raz, niezależnie od ile bucketów ją łączy.""" + baker.make("bpp.Autor", nazwisko="Smith", imiona="John") + baker.make("bpp.Autor", nazwisko="Smith", imiona="John") + meta = build_autor_meta() + buckets = build_buckets(meta) + pairs = list(generate_pairs(buckets, meta, ignored_pks=set(), notadup_pks=set())) + pair_set = [(p, q) for p, q, _, _ in pairs] + assert len(pair_set) == len(set(pair_set)) + + +@pytest.mark.django_db +def test_ignored_excluded(): + a1 = baker.make("bpp.Autor", nazwisko="Brown", imiona="Bob") + baker.make("bpp.Autor", nazwisko="Brown", imiona="Bob") + meta = build_autor_meta() + buckets = build_buckets(meta) + pairs = list(generate_pairs(buckets, meta, ignored_pks={a1.pk}, notadup_pks=set())) + assert pairs == [] + + +@pytest.mark.django_db +def test_notadup_excluded(): + a1 = baker.make("bpp.Autor", nazwisko="Wilson", imiona="Tim") + baker.make("bpp.Autor", nazwisko="Wilson", imiona="Tim") + meta = build_autor_meta() + buckets = build_buckets(meta) + pairs = list(generate_pairs(buckets, meta, ignored_pks=set(), notadup_pks={a1.pk})) + assert pairs == [] + + +@pytest.mark.django_db +def test_oversized_bucket_skipped(): + """Bucket > BUCKET_MAX_SIZE jest pomijany.""" + baker.make( + "bpp.Autor", + nazwisko="PopularName", + _quantity=BUCKET_MAX_SIZE + 1, + ) + meta = build_autor_meta() + buckets = build_buckets(meta) + pairs = list(generate_pairs(buckets, meta, ignored_pks=set(), notadup_pks=set())) + # Dla tego bucketu (PopularName) — żadne pary nie powinny zostać wyemitowane + assert pairs == [] diff --git a/src/deduplikator_autorow/utils/analysis_meta.py b/src/deduplikator_autorow/utils/analysis_meta.py index d3639e723..34a6d3244 100644 --- a/src/deduplikator_autorow/utils/analysis_meta.py +++ b/src/deduplikator_autorow/utils/analysis_meta.py @@ -61,6 +61,22 @@ def analiza_pary_meta(a: dict, b: dict) -> tuple[int, list[str]]: # noqa: C901 ): score += 30 reasons.append("podobne nazwisko (zawieranie)") + else: + parts_a = set(a.get("nazwisko_parts") or []) + parts_b = set(b.get("nazwisko_parts") or []) + common_parts = parts_a & parts_b + if common_parts and (len(parts_a) > 1 or len(parts_b) > 1): + if parts_a == parts_b: + # Pełny zestaw członów się zgadza (np. permutacja + # 'gal-cisoń' ↔ 'cisoń-gal'). + score += 35 + reasons.append("identyczne człony nazwiska złożonego (permutacja)") + else: + score += 20 + reasons.append( + f"wspólny człon nazwiska złożonego " + f"({', '.join(sorted(common_parts))})" + ) if ( a["nazwisko_norm"] diff --git a/src/deduplikator_autorow/utils/search_general.py b/src/deduplikator_autorow/utils/search_general.py new file mode 100644 index 000000000..cdd17aac1 --- /dev/null +++ b/src/deduplikator_autorow/utils/search_general.py @@ -0,0 +1,55 @@ +"""Generator par kandydatów w fazie general — in-memory bucket comparisons.""" + +import logging + +from .analysis_meta import analiza_pary_meta + +logger = logging.getLogger(__name__) + +BUCKET_MAX_SIZE = 200 +MIN_CONFIDENCE_TO_STORE = 50 + + +def generate_pairs( + buckets: dict[str, list[int]], + meta: dict[int, dict], + ignored_pks: set[int], + notadup_pks: set[int], + min_confidence: int = MIN_CONFIDENCE_TO_STORE, +): + """Yield (pk_a, pk_b, score, reasons) gdzie pk_a < pk_b i score >= min_confidence. + + Args: + buckets: {nazwisko_norm -> [pk1, pk2, ...]} z `build_buckets`. + meta: {pk -> meta dict} z `build_autor_meta`. + ignored_pks: PK do pominięcia jako pivot/kandydat (z IgnoredAuthor). + notadup_pks: PK oznaczone jako NotADuplicate (też pomijane). + min_confidence: próg score-u poniżej którego para nie jest emitowana. + """ + seen_pairs: set[tuple[int, int]] = set() + skipped_buckets = 0 + for bucket_name, pks in buckets.items(): + if len(pks) > BUCKET_MAX_SIZE: + logger.warning( + "Skipping oversized bucket '%s' (%d members)", + bucket_name, + len(pks), + ) + skipped_buckets += 1 + continue + active = [p for p in pks if p not in ignored_pks] + for i, pk_a in enumerate(active): + for pk_b in active[i + 1 :]: + if pk_a == pk_b: + continue + key = (min(pk_a, pk_b), max(pk_a, pk_b)) + if key in seen_pairs: + continue + seen_pairs.add(key) + if key[0] in notadup_pks or key[1] in notadup_pks: + continue + score, reasons = analiza_pary_meta(meta[key[0]], meta[key[1]]) + if score >= min_confidence: + yield key[0], key[1], score, reasons + if skipped_buckets: + logger.info("Skipped %d oversized buckets in general phase", skipped_buckets) From 8726db3f077c6cdd8085ab7a0486beb45ec8f8d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Fri, 1 May 2026 10:20:28 +0200 Subject: [PATCH 09/25] =?UTF-8?q?feat(deduplikator):=20=5Frun=5Fgeneral=5F?= =?UTF-8?q?phase=20w=20tasks.py=20=E2=80=94=20algorytm=20fazy=20general?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- src/deduplikator_autorow/tasks.py | 97 +++++++++++++++ .../tests/test_general_phase.py | 111 ++++++++++++++++++ 2 files changed, 208 insertions(+) create mode 100644 src/deduplikator_autorow/tests/test_general_phase.py diff --git a/src/deduplikator_autorow/tasks.py b/src/deduplikator_autorow/tasks.py index 0fa4a5dd3..2bc456c85 100644 --- a/src/deduplikator_autorow/tasks.py +++ b/src/deduplikator_autorow/tasks.py @@ -217,6 +217,103 @@ def _process_author_duplicates(osoba_z_instytucji, scan_run, min_confidence): return candidates +def _run_general_phase(scan_run, min_confidence=MIN_CONFIDENCE_TO_STORE): + """Faza 2 skanu — duplikaty general (no SQL on hot path). + + Algorytm: + 1. build_autor_meta + build_buckets — pre-load wszystkich autorów. + 2. Read IgnoredAuthor / NotADuplicate exclusions. + 3. generate_pairs — pary score >= min_confidence. + 4. find_clusters — connected components. + 5. Cluster-skip jeśli ktokolwiek w klastrze ma OsobaZInstytucji. + 6. Pick main przez hierarchię B; emit pary (main, dup) jako + DuplicateCandidate(scan_mode='general'). + 7. Sprawdza scan_run.status == CANCELLED między batchami. + """ + from .models import ( + DuplicateCandidate, + DuplicateScanRun, + IgnoredAuthor, + NotADuplicate, + ) + from .utils.analysis_meta import analiza_pary_meta + from .utils.cluster import find_clusters + from .utils.main_selection import pick_main_pk + from .utils.meta import build_autor_meta, build_buckets + from .utils.search_general import generate_pairs + + logger.info("General phase: building meta cache...") + meta = build_autor_meta() + buckets = build_buckets(meta) + logger.info("General phase: %d autorów, %d bucketów", len(meta), len(buckets)) + + ignored_pks = set(IgnoredAuthor.objects.values_list("autor_id", flat=True)) + notadup_pks = set(NotADuplicate.objects.values_list("autor_id", flat=True)) + + pairs_data: dict[tuple[int, int], tuple[int, list[str]]] = {} + for pk_a, pk_b, score, reasons in generate_pairs( + buckets, meta, ignored_pks, notadup_pks, min_confidence + ): + pairs_data[(pk_a, pk_b)] = (score, reasons) + logger.info("General phase: znaleziono %d par", len(pairs_data)) + + clusters = find_clusters(list(pairs_data.keys())) + logger.info("General phase: %d klastrów wstępnych", len(clusters)) + + skipped_count = 0 + candidates_to_create: list[DuplicateCandidate] = [] + for cluster in clusters: + if any(meta[pk]["ma_osoba_z_instytucji"] for pk in cluster): + skipped_count += 1 + continue + main_pk = pick_main_pk(cluster, meta) + for dup_pk in cluster - {main_pk}: + key = (min(main_pk, dup_pk), max(main_pk, dup_pk)) + if key in pairs_data: + score, reasons = pairs_data[key] + else: + score, reasons = analiza_pary_meta(meta[main_pk], meta[dup_pk]) + main_obj = meta[main_pk]["obj"] + dup_obj = meta[dup_pk]["obj"] + candidates_to_create.append( + DuplicateCandidate( + scan_run=scan_run, + main_autor=main_obj, + duplicate_autor=dup_obj, + confidence_score=score, + confidence_percent=normalize_confidence(score), + reasons=reasons, + priority=calculate_author_priority(dup_obj), + main_autor_name=str(main_obj), + duplicate_autor_name=str(dup_obj), + main_publications_count=meta[main_pk]["publikacje_count"], + duplicate_publications_count=meta[dup_pk]["publikacje_count"], + scan_mode="general", + ) + ) + if len(candidates_to_create) >= 1000: + with transaction.atomic(): + DuplicateCandidate.objects.bulk_create( + candidates_to_create, ignore_conflicts=True + ) + candidates_to_create = [] + scan_run.refresh_from_db() + if scan_run.status == DuplicateScanRun.Status.CANCELLED: + logger.info("General phase cancelled mid-batch") + return + + if candidates_to_create: + with transaction.atomic(): + DuplicateCandidate.objects.bulk_create( + candidates_to_create, ignore_conflicts=True + ) + + logger.info( + "General phase: %d klastrów pominiętych (z OsobaZInstytucji)", + skipped_count, + ) + + @shared_task(bind=True, name="deduplikator_autorow.scan_for_duplicates") def scan_for_duplicates(self, user_id=None, min_confidence=MIN_CONFIDENCE_TO_STORE): """ diff --git a/src/deduplikator_autorow/tests/test_general_phase.py b/src/deduplikator_autorow/tests/test_general_phase.py new file mode 100644 index 000000000..76cb48e2b --- /dev/null +++ b/src/deduplikator_autorow/tests/test_general_phase.py @@ -0,0 +1,111 @@ +"""Testy fazy general w skanowaniu duplikatów.""" + +import pytest +from model_bakery import baker + +from deduplikator_autorow.models import ( + DuplicateCandidate, + DuplicateScanRun, + IgnoredAuthor, + NotADuplicate, +) +from deduplikator_autorow.tasks import _run_general_phase + + +@pytest.mark.django_db +def test_general_finds_simple_pair(): + """Dwóch autorów o tym samym nazwisku/imieniu, żaden bez OsobaZInstytucji.""" + baker.make("bpp.Autor", nazwisko="Kowalski", imiona="Jan") + baker.make("bpp.Autor", nazwisko="Kowalski", imiona="Jan") + scan = DuplicateScanRun.objects.create() + _run_general_phase(scan, min_confidence=50) + cands = DuplicateCandidate.objects.filter(scan_run=scan, scan_mode="general") + assert cands.count() == 1 + + +@pytest.mark.django_db +def test_general_skips_cluster_with_osoba_instytucji(): + """Klaster {A, B, C} gdzie B ma OsobaZInstytucji → klaster pominięty.""" + baker.make("bpp.Autor", nazwisko="Nowak", imiona="Anna") + b = baker.make("bpp.Autor", nazwisko="Nowak", imiona="Anna") + baker.make("bpp.Autor", nazwisko="Nowak", imiona="Anna") + scientist = baker.make("pbn_api.Scientist") + b.pbn_uid = scientist + b.save() + baker.make("pbn_api.OsobaZInstytucji", personId=scientist) + + scan = DuplicateScanRun.objects.create() + _run_general_phase(scan, min_confidence=50) + cands = DuplicateCandidate.objects.filter(scan_run=scan, scan_mode="general") + assert cands.count() == 0 + + +@pytest.mark.django_db +def test_general_main_chosen_by_orcid(): + """Z dwóch autorów ORCID-owany wygrywa jako main.""" + a = baker.make("bpp.Autor", nazwisko="Adams", imiona="Eve", orcid=None) + b = baker.make( + "bpp.Autor", + nazwisko="Adams", + imiona="Eve", + orcid="0000-0001-2345-6789", + ) + scan = DuplicateScanRun.objects.create() + _run_general_phase(scan, min_confidence=50) + cand = DuplicateCandidate.objects.get(scan_run=scan, scan_mode="general") + assert cand.main_autor_id == b.pk + assert cand.duplicate_autor_id == a.pk + + +@pytest.mark.django_db +def test_general_pk_tiebreaker(): + """Wszystko równe → niższy pk wygrywa jako main.""" + a = baker.make("bpp.Autor", nazwisko="Black", imiona="Carl") + b = baker.make("bpp.Autor", nazwisko="Black", imiona="Carl") + lower_pk = min(a.pk, b.pk) + higher_pk = max(a.pk, b.pk) + + scan = DuplicateScanRun.objects.create() + _run_general_phase(scan, min_confidence=50) + cand = DuplicateCandidate.objects.get(scan_run=scan, scan_mode="general") + assert cand.main_autor_id == lower_pk + assert cand.duplicate_autor_id == higher_pk + + +@pytest.mark.django_db +def test_general_respects_ignored_author(): + a = baker.make("bpp.Autor", nazwisko="Yellow", imiona="Sun") + baker.make("bpp.Autor", nazwisko="Yellow", imiona="Sun") + user = baker.make("bpp.BppUser") + IgnoredAuthor.objects.create(autor=a, created_by=user) + + scan = DuplicateScanRun.objects.create() + _run_general_phase(scan, min_confidence=50) + assert DuplicateCandidate.objects.filter(scan_run=scan).count() == 0 + + +@pytest.mark.django_db +def test_general_respects_not_a_duplicate(): + a = baker.make("bpp.Autor", nazwisko="Green", imiona="Mike") + baker.make("bpp.Autor", nazwisko="Green", imiona="Mike") + user = baker.make("bpp.BppUser") + NotADuplicate.objects.create(autor=a, created_by=user) + + scan = DuplicateScanRun.objects.create() + _run_general_phase(scan, min_confidence=50) + assert DuplicateCandidate.objects.filter(scan_run=scan).count() == 0 + + +@pytest.mark.django_db +def test_general_transitive_cluster(): + """Trzech 'Linker Jan' tworzy klaster {A,B,C} → 2 pary z jednym main.""" + a = baker.make("bpp.Autor", nazwisko="Linker", imiona="Jan") + b = baker.make("bpp.Autor", nazwisko="Linker", imiona="Jan") + c = baker.make("bpp.Autor", nazwisko="Linker", imiona="Jan") + scan = DuplicateScanRun.objects.create() + _run_general_phase(scan, min_confidence=50) + cands = DuplicateCandidate.objects.filter(scan_run=scan, scan_mode="general") + assert cands.count() == 2 + main_pks = {c.main_autor_id for c in cands} + assert len(main_pks) == 1 + assert main_pks == {min(a.pk, b.pk, c.pk)} From 6eece6aa699e8cdeb8c46cc40ced140b6ae69b92 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Fri, 1 May 2026 10:24:35 +0200 Subject: [PATCH 10/25] feat(deduplikator): scan_for_duplicates dwufazowo (PBN + general) z PARTIAL_COMPLETED MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wyodrębnia istniejące ciało scan_for_duplicates do _run_pbn_phase i przepina task na orkiestrację dwóch faz (PBN → general) w jednym DuplicateScanRun. Cancellation w fazie PBN daje status CANCELLED (bez wyników general), w fazie general daje PARTIAL_COMPLETED (wyniki PBN zachowane). Pole `phase` ustawiane na 'pbn'/'general' w trakcie pracy. Zachowane behaviour PBN: replace mode, polling scan_run.status między autorami, periodyczny update progress. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/deduplikator_autorow/tasks.py | 207 +++++++++++------- .../tests/test_combined_scan.py | 104 +++++++++ 2 files changed, 230 insertions(+), 81 deletions(-) create mode 100644 src/deduplikator_autorow/tests/test_combined_scan.py diff --git a/src/deduplikator_autorow/tasks.py b/src/deduplikator_autorow/tasks.py index 2bc456c85..a112dec0a 100644 --- a/src/deduplikator_autorow/tasks.py +++ b/src/deduplikator_autorow/tasks.py @@ -314,124 +314,169 @@ def _run_general_phase(scan_run, min_confidence=MIN_CONFIDENCE_TO_STORE): ) -@shared_task(bind=True, name="deduplikator_autorow.scan_for_duplicates") -def scan_for_duplicates(self, user_id=None, min_confidence=MIN_CONFIDENCE_TO_STORE): - """ - Background task to scan all authors for potential duplicates. - - This task: - 1. Creates a DuplicateScanRun record - 2. Deletes all existing DuplicateCandidate records (replace mode) - 3. Iterates through all OsobaZInstytucji - 4. For each, calls szukaj_kopii() to find candidates - 5. For each candidate, calls analiza_duplikatow() and stores in DuplicateCandidate - 6. Updates progress periodically - 7. Marks run as completed +def _run_pbn_phase(scan_run, min_confidence=MIN_CONFIDENCE_TO_STORE): + """Faza 1 skanu — duplikaty PBN (OsobaZInstytucji). - Args: - user_id: Optional ID of the user who triggered the scan - min_confidence: Minimum confidence score to store a candidate (default: 50) + Iteruje przez wszystkie OsobaZInstytucji (z wyjątkiem IgnoredScientist), + dla każdej szuka kopii (`szukaj_kopii`), analizuje (`analiza_duplikatow`) + i tworzy DuplicateCandidate. Polluje `scan_run.status` między autorami — + jeśli zewnętrzny `cancel_scan` ustawił CANCELLED, kończy wcześnie + (status pozostaje CANCELLED — caller decyduje o finalizacji). - Returns: - dict: Result with status, scan_run_id, and statistics + Aktualizuje pola `total_authors_to_scan`, `authors_scanned` i + `duplicates_found` na `scan_run` w trakcie pracy. """ from pbn_api.models import OsobaZInstytucji from .models import DuplicateCandidate, DuplicateScanRun, IgnoredScientist - logger.info("Starting duplicate scan task...") - - user = _get_user_by_id(user_id) - - scan_run = DuplicateScanRun.objects.create( - status=DuplicateScanRun.Status.RUNNING, - created_by=user, - celery_task_id=self.request.id or "", + ignored_scientist_ids = set( + IgnoredScientist.objects.values_list("scientist_id", flat=True) ) - try: - deleted_count = DuplicateCandidate.objects.all().delete()[0] - logger.info(f"Deleted {deleted_count} existing candidates") + osoby_query = OsobaZInstytucji.objects.select_related("personId").all() + if ignored_scientist_ids: + osoby_query = osoby_query.exclude(personId__pk__in=ignored_scientist_ids) - ignored_scientist_ids = set( - IgnoredScientist.objects.values_list("scientist_id", flat=True) - ) - - osoby_query = OsobaZInstytucji.objects.select_related("personId").all() - if ignored_scientist_ids: - osoby_query = osoby_query.exclude(personId__pk__in=ignored_scientist_ids) - - total_count = osoby_query.count() - scan_run.total_authors_to_scan = total_count - scan_run.save(update_fields=["total_authors_to_scan"]) - - logger.info(f"Scanning {total_count} authors for duplicates...") + total_count = osoby_query.count() + scan_run.total_authors_to_scan = total_count + scan_run.save(update_fields=["total_authors_to_scan"]) - authors_scanned = 0 - duplicates_found = 0 - candidates_to_create = [] + logger.info(f"PBN phase: scanning {total_count} authors...") - for osoba_z_instytucji in osoby_query.iterator(): - scan_run.refresh_from_db() - if scan_run.status == DuplicateScanRun.Status.CANCELLED: - logger.info("Scan cancelled by user") - return { - "status": "cancelled", - "scan_run_id": scan_run.pk, - "authors_scanned": authors_scanned, - "duplicates_found": duplicates_found, - } + authors_scanned = 0 + duplicates_found = 0 + candidates_to_create = [] - authors_scanned += 1 - - new_candidates = _process_author_duplicates( - osoba_z_instytucji, scan_run, min_confidence - ) - candidates_to_create.extend(new_candidates) - duplicates_found += len(new_candidates) - - if len(candidates_to_create) >= 1000: + for osoba_z_instytucji in osoby_query.iterator(): + scan_run.refresh_from_db() + if scan_run.status == DuplicateScanRun.Status.CANCELLED: + logger.info("PBN phase cancelled by user") + if candidates_to_create: with transaction.atomic(): DuplicateCandidate.objects.bulk_create( candidates_to_create, ignore_conflicts=True ) - candidates_to_create = [] + scan_run.authors_scanned = authors_scanned + scan_run.duplicates_found = duplicates_found + scan_run.save(update_fields=["authors_scanned", "duplicates_found"]) + return - if authors_scanned % PROGRESS_UPDATE_INTERVAL == 0: - scan_run.authors_scanned = authors_scanned - scan_run.duplicates_found = duplicates_found - scan_run.save(update_fields=["authors_scanned", "duplicates_found"]) - logger.info( - f"Progress: {authors_scanned}/{total_count} authors, " - f"{duplicates_found} duplicates found" - ) + authors_scanned += 1 + + new_candidates = _process_author_duplicates( + osoba_z_instytucji, scan_run, min_confidence + ) + candidates_to_create.extend(new_candidates) + duplicates_found += len(new_candidates) - if candidates_to_create: + if len(candidates_to_create) >= 1000: with transaction.atomic(): DuplicateCandidate.objects.bulk_create( candidates_to_create, ignore_conflicts=True ) + candidates_to_create = [] + + if authors_scanned % PROGRESS_UPDATE_INTERVAL == 0: + scan_run.authors_scanned = authors_scanned + scan_run.duplicates_found = duplicates_found + scan_run.save(update_fields=["authors_scanned", "duplicates_found"]) + logger.info( + f"PBN progress: {authors_scanned}/{total_count} authors, " + f"{duplicates_found} duplicates found" + ) + + if candidates_to_create: + with transaction.atomic(): + DuplicateCandidate.objects.bulk_create( + candidates_to_create, ignore_conflicts=True + ) + + scan_run.authors_scanned = authors_scanned + scan_run.duplicates_found = duplicates_found + scan_run.save(update_fields=["authors_scanned", "duplicates_found"]) + + logger.info( + f"PBN phase done: {authors_scanned} authors scanned, " + f"{duplicates_found} duplicates found" + ) + + +@shared_task(bind=True, name="deduplikator_autorow.scan_for_duplicates") +def scan_for_duplicates(self, user_id=None, min_confidence=MIN_CONFIDENCE_TO_STORE): + """Combined task: faza PBN + faza general w jednym przebiegu. + + Statusy końcowe: + - COMPLETED: obie fazy ukończone. + - PARTIAL_COMPLETED: faza PBN OK, faza general anulowana → wyniki PBN + dostępne. + - CANCELLED: faza PBN anulowana → brak wyników. + - FAILED: nieobsłużony wyjątek. + """ + from .models import DuplicateCandidate, DuplicateScanRun + + logger.info("Starting duplicate scan task (combined PBN + general)...") + + user = _get_user_by_id(user_id) + scan_run = DuplicateScanRun.objects.create( + status=DuplicateScanRun.Status.RUNNING, + created_by=user, + celery_task_id=self.request.id or "", + ) + + try: + # Replace mode: clear all previous candidates + deleted_count = DuplicateCandidate.objects.all().delete()[0] + logger.info(f"Deleted {deleted_count} existing candidates") + + # FAZA 1: PBN + scan_run.phase = "pbn" + scan_run.save(update_fields=["phase"]) + _run_pbn_phase(scan_run, min_confidence) + scan_run.refresh_from_db() + if scan_run.status == DuplicateScanRun.Status.CANCELLED: + scan_run.finished_at = timezone.now() + scan_run.save(update_fields=["finished_at"]) + logger.info("Scan cancelled in PBN phase") + return { + "status": "cancelled", + "scan_run_id": scan_run.pk, + } + + # FAZA 2: general + scan_run.phase = "general" + scan_run.save(update_fields=["phase"]) + _run_general_phase(scan_run, min_confidence) + scan_run.refresh_from_db() + if scan_run.status == DuplicateScanRun.Status.CANCELLED: + scan_run.status = DuplicateScanRun.Status.PARTIAL_COMPLETED + scan_run.finished_at = timezone.now() + scan_run.save(update_fields=["status", "finished_at"]) + logger.info("Scan cancelled in general phase → PARTIAL_COMPLETED") + return { + "status": "partial_completed", + "scan_run_id": scan_run.pk, + } + total_cands = DuplicateCandidate.objects.filter(scan_run=scan_run).count() scan_run.status = DuplicateScanRun.Status.COMPLETED scan_run.finished_at = timezone.now() - scan_run.authors_scanned = authors_scanned - scan_run.duplicates_found = duplicates_found + scan_run.duplicates_found = total_cands scan_run.save() logger.info( - f"Scan completed: {authors_scanned} authors scanned, " - f"{duplicates_found} duplicates found" + f"Scan completed: {scan_run.authors_scanned} authors scanned, " + f"{total_cands} duplicates found" ) return { "status": "success", "scan_run_id": scan_run.pk, - "authors_scanned": authors_scanned, - "duplicates_found": duplicates_found, + "duplicates_found": total_cands, } except Exception as e: - logger.error(f"Error during duplicate scan: {str(e)}", exc_info=True) + logger.exception("Error during duplicate scan") scan_run.status = DuplicateScanRun.Status.FAILED scan_run.finished_at = timezone.now() scan_run.error_message = str(e) diff --git a/src/deduplikator_autorow/tests/test_combined_scan.py b/src/deduplikator_autorow/tests/test_combined_scan.py new file mode 100644 index 000000000..b748a0101 --- /dev/null +++ b/src/deduplikator_autorow/tests/test_combined_scan.py @@ -0,0 +1,104 @@ +"""Testy combined task scan_for_duplicates (PBN + general).""" + +from unittest import mock + +import pytest +from model_bakery import baker + +from deduplikator_autorow.models import DuplicateCandidate, DuplicateScanRun +from deduplikator_autorow.tasks import scan_for_duplicates + + +@pytest.mark.django_db +def test_combined_scan_runs_both_phases_status_completed(): + """Sukces obu faz → status COMPLETED.""" + result = scan_for_duplicates.apply().result + assert result["status"] == "success" + scan = DuplicateScanRun.objects.get(pk=result["scan_run_id"]) + assert scan.status == DuplicateScanRun.Status.COMPLETED + + +@pytest.mark.django_db +def test_combined_scan_general_finds_duplicates(): + """Faza general dodaje DuplicateCandidate(scan_mode='general').""" + baker.make("bpp.Autor", nazwisko="Hawkins", imiona="Lee") + baker.make("bpp.Autor", nazwisko="Hawkins", imiona="Lee") + result = scan_for_duplicates.apply().result + scan = DuplicateScanRun.objects.get(pk=result["scan_run_id"]) + assert ( + DuplicateCandidate.objects.filter(scan_run=scan, scan_mode="general").count() + >= 1 + ) + + +@pytest.mark.django_db +def test_cancel_during_general_phase_leaves_partial_completed(): + """Anulowanie w fazie 2 (general) → PARTIAL_COMPLETED.""" + baker.make("bpp.Autor", nazwisko="Igor", imiona="Test") + baker.make("bpp.Autor", nazwisko="Igor", imiona="Test") + + def fake_general(scan_run, *args, **kwargs): + scan_run.status = DuplicateScanRun.Status.CANCELLED + scan_run.save(update_fields=["status"]) + + with mock.patch( + "deduplikator_autorow.tasks._run_general_phase", + side_effect=fake_general, + ): + result = scan_for_duplicates.apply().result + + scan = DuplicateScanRun.objects.get(pk=result["scan_run_id"]) + assert scan.status == DuplicateScanRun.Status.PARTIAL_COMPLETED + assert result["status"] == "partial_completed" + + +@pytest.mark.django_db +def test_cancel_during_pbn_phase_leaves_cancelled(): + """Anulowanie w fazie 1 (PBN) → CANCELLED, faza 2 nie startuje.""" + + def fake_pbn(scan_run, *args, **kwargs): + scan_run.status = DuplicateScanRun.Status.CANCELLED + scan_run.save(update_fields=["status"]) + + with ( + mock.patch( + "deduplikator_autorow.tasks._run_pbn_phase", + side_effect=fake_pbn, + ), + mock.patch("deduplikator_autorow.tasks._run_general_phase") as general_mock, + ): + result = scan_for_duplicates.apply().result + general_mock.assert_not_called() + + scan = DuplicateScanRun.objects.get(pk=result["scan_run_id"]) + assert scan.status == DuplicateScanRun.Status.CANCELLED + assert result["status"] == "cancelled" + + +@pytest.mark.django_db +def test_phase_field_set_during_run(): + """Pole `phase` ustawione na 'pbn' przy fazie 1, 'general' przy fazie 2.""" + phases_seen = [] + + from deduplikator_autorow import tasks as deduptasks + + original_pbn = deduptasks._run_pbn_phase + original_general = deduptasks._run_general_phase + + def spy_pbn(scan_run, *a, **kw): + scan_run.refresh_from_db() + phases_seen.append(("pbn", scan_run.phase)) + return original_pbn(scan_run, *a, **kw) + + def spy_general(scan_run, *a, **kw): + scan_run.refresh_from_db() + phases_seen.append(("general", scan_run.phase)) + return original_general(scan_run, *a, **kw) + + with ( + mock.patch.object(deduptasks, "_run_pbn_phase", spy_pbn), + mock.patch.object(deduptasks, "_run_general_phase", spy_general), + ): + scan_for_duplicates.apply() + + assert phases_seen == [("pbn", "pbn"), ("general", "general")] From ebcec98ded51dae29d6eea0b4bb327d4bbfb806a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Fri, 1 May 2026 10:30:00 +0200 Subject: [PATCH 11/25] feat(deduplikator): mode filter w widoku + get_latest_usable_scan (PARTIAL_COMPLETED) --- .../tests/test_view_mode_filter.py | 124 ++++++++++++++++++ src/deduplikator_autorow/utils/counters.py | 21 +++ src/deduplikator_autorow/views.py | 66 +++++++--- 3 files changed, 192 insertions(+), 19 deletions(-) create mode 100644 src/deduplikator_autorow/tests/test_view_mode_filter.py diff --git a/src/deduplikator_autorow/tests/test_view_mode_filter.py b/src/deduplikator_autorow/tests/test_view_mode_filter.py new file mode 100644 index 000000000..8027341d5 --- /dev/null +++ b/src/deduplikator_autorow/tests/test_view_mode_filter.py @@ -0,0 +1,124 @@ +"""Testy filtra mode w widoku duplicate_authors.""" + +import pytest +from django.contrib.auth.models import Group +from django.urls import reverse +from django.utils import timezone +from model_bakery import baker + +from bpp.const import GR_WPROWADZANIE_DANYCH +from deduplikator_autorow.models import DuplicateCandidate, DuplicateScanRun + + +@pytest.fixture +def auth_client(client, db): + user = baker.make("bpp.BppUser", is_active=True) + user.set_password("xx") + user.save() + grp, _ = Group.objects.get_or_create(name=GR_WPROWADZANIE_DANYCH) + user.groups.add(grp) + client.force_login(user) + return client + + +@pytest.fixture +def scan_with_both_modes(db): + scan = DuplicateScanRun.objects.create( + status=DuplicateScanRun.Status.COMPLETED, + finished_at=timezone.now(), + ) + a1 = baker.make("bpp.Autor", nazwisko="Pbn1", imiona="Jan") + a2 = baker.make("bpp.Autor", nazwisko="Pbn1", imiona="Jan") + g1 = baker.make("bpp.Autor", nazwisko="Gen1", imiona="Anna") + g2 = baker.make("bpp.Autor", nazwisko="Gen1", imiona="Anna") + DuplicateCandidate.objects.create( + scan_run=scan, + main_autor=a1, + duplicate_autor=a2, + confidence_score=80, + confidence_percent=0.6, + main_autor_name="Pbn1 Jan", + duplicate_autor_name="Pbn1 Jan", + scan_mode="pbn", + ) + DuplicateCandidate.objects.create( + scan_run=scan, + main_autor=g1, + duplicate_autor=g2, + confidence_score=80, + confidence_percent=0.6, + main_autor_name="Gen1 Anna", + duplicate_autor_name="Gen1 Anna", + scan_mode="general", + ) + return scan + + +def test_view_mode_filter_pbn(auth_client, scan_with_both_modes): + response = auth_client.get( + reverse("deduplikator_autorow:duplicate_authors") + "?mode=pbn" + ) + assert response.status_code == 200 + content = response.content.decode() + assert "Pbn1" in content + assert "Gen1" not in content + + +def test_view_mode_filter_general(auth_client, scan_with_both_modes): + response = auth_client.get( + reverse("deduplikator_autorow:duplicate_authors") + "?mode=general" + ) + assert response.status_code == 200 + content = response.content.decode() + assert "Gen1" in content + assert "Pbn1" not in content + + +def test_view_mode_filter_both_default(auth_client, scan_with_both_modes): + """Default mode (no GET param) — pokazuje któryś (zwykle pierwszy w sort order).""" + response = auth_client.get(reverse("deduplikator_autorow:duplicate_authors")) + assert response.status_code == 200 + # Powinien być w kontekście choć jeden z dwóch + content = response.content.decode() + assert "Pbn1" in content or "Gen1" in content + + +def test_view_pending_counters_split_by_mode(auth_client, scan_with_both_modes): + """Counters per-tryb.""" + response = auth_client.get(reverse("deduplikator_autorow:duplicate_authors")) + assert response.context["pending_pbn_count"] == 1 + assert response.context["pending_general_count"] == 1 + + +def test_view_invalid_mode_falls_back_to_both(auth_client, scan_with_both_modes): + response = auth_client.get( + reverse("deduplikator_autorow:duplicate_authors") + "?mode=zzzunknown" + ) + assert response.status_code == 200 + assert response.context["mode"] == "both" + + +def test_view_partial_completed_scan_used(auth_client): + """get_latest_usable_scan zwraca PARTIAL_COMPLETED.""" + scan = DuplicateScanRun.objects.create( + status=DuplicateScanRun.Status.PARTIAL_COMPLETED, + finished_at=timezone.now(), + ) + a1 = baker.make("bpp.Autor", nazwisko="Sole", imiona="One") + a2 = baker.make("bpp.Autor", nazwisko="Sole", imiona="One") + DuplicateCandidate.objects.create( + scan_run=scan, + main_autor=a1, + duplicate_autor=a2, + confidence_score=80, + confidence_percent=0.6, + main_autor_name="x", + duplicate_autor_name="y", + scan_mode="pbn", + ) + response = auth_client.get(reverse("deduplikator_autorow:duplicate_authors")) + assert response.status_code == 200 + # context "completed_scan" should still be set even though status is PARTIAL_COMPLETED + # (the field is named completed_scan in the existing view but its semantics are + # "scan with usable results") + assert response.context.get("completed_scan") is not None diff --git a/src/deduplikator_autorow/utils/counters.py b/src/deduplikator_autorow/utils/counters.py index 76657d4bd..c6fd3cbfe 100644 --- a/src/deduplikator_autorow/utils/counters.py +++ b/src/deduplikator_autorow/utils/counters.py @@ -21,6 +21,27 @@ def get_latest_completed_scan(): ) +def get_latest_usable_scan(): + """Pobiera ostatnie skanowanie z użytecznymi wynikami. + + "Użyteczne" = COMPLETED lub PARTIAL_COMPLETED (faza PBN ukończona, + nawet jeśli general anulowana). + + Returns: + DuplicateScanRun lub None + """ + return ( + DuplicateScanRun.objects.filter( + status__in=[ + DuplicateScanRun.Status.COMPLETED, + DuplicateScanRun.Status.PARTIAL_COMPLETED, + ] + ) + .order_by("-finished_at") + .first() + ) + + def get_latest_scan_stats(): """ Pobiera statystyki ostatniego skanowania. diff --git a/src/deduplikator_autorow/views.py b/src/deduplikator_autorow/views.py index d3819abc5..4c13bb829 100644 --- a/src/deduplikator_autorow/views.py +++ b/src/deduplikator_autorow/views.py @@ -33,6 +33,7 @@ search_author_by_lastname, znajdz_pierwszego_autora_z_duplikatami, ) +from .utils.counters import get_latest_usable_scan # Minimalny próg pewności do wyświetlania duplikatów # Duplikaty z pewnością poniżej tego progu nie będą pokazywane @@ -196,7 +197,12 @@ def duplicate_authors_view(request): # noqa: C901 # Get scan status running_scan = get_running_scan() - completed_scan = get_latest_completed_scan() + completed_scan = get_latest_usable_scan() + + # Filter mode: pbn|general|both (default both) + mode = request.GET.get("mode", "both") + if mode not in ("pbn", "general", "both"): + mode = "both" # Common context not_duplicate_count = NotADuplicate.objects.count() @@ -232,6 +238,10 @@ def duplicate_authors_view(request): # noqa: C901 "completed_scan": completed_scan, "no_scan_available": not completed_scan and not running_scan, "pending_candidates_count": 0, + "pending_pbn_count": 0, + "pending_general_count": 0, + # Filter mode (pbn|general|both) + "mode": mode, # Navigation "skip_count": 0, # PBN data freshness @@ -257,6 +267,16 @@ def duplicate_authors_view(request): # noqa: C901 ).count() context["pending_candidates_count"] = pending_count context["total_authors_with_duplicates"] = pending_count + context["pending_pbn_count"] = DuplicateCandidate.objects.filter( + scan_run=completed_scan, + status=DuplicateCandidate.Status.PENDING, + scan_mode="pbn", + ).count() + context["pending_general_count"] = DuplicateCandidate.objects.filter( + scan_run=completed_scan, + status=DuplicateCandidate.Status.PENDING, + scan_mode="general", + ).count() # Handle search by lastname search_lastname = request.GET.get("search_lastname", "").strip() @@ -273,6 +293,8 @@ def duplicate_authors_view(request): # noqa: C901 .select_related("main_autor", "duplicate_autor") .order_by("-priority", "-confidence_score") ) + if mode != "both": + candidates = candidates.filter(scan_mode=mode) context["search_results_count"] = ( candidates.values("main_autor").distinct().count() @@ -294,7 +316,7 @@ def duplicate_authors_view(request): # noqa: C901 # Get next author with pending duplicates using offset glowny_autor, candidates_for_author, skip_count = _get_next_candidate_group( - completed_scan, skip_count=skip_count + completed_scan, skip_count=skip_count, mode=mode ) context["skip_count"] = skip_count @@ -640,15 +662,6 @@ def download_duplicates_xlsx(request): return redirect("deduplikator_autorow:duplicate_authors") -def get_latest_completed_scan(): - """Get the most recent completed scan run.""" - return ( - DuplicateScanRun.objects.filter(status=DuplicateScanRun.Status.COMPLETED) - .order_by("-finished_at") - .first() - ) - - def get_running_scan(): """Get the currently running scan, if any.""" return DuplicateScanRun.objects.filter( @@ -777,26 +790,41 @@ def _get_pending_candidates_for_main_autor(main_autor_id, scan_run): ) -def _get_next_candidate_group(scan_run, skip_count=0): +def _get_next_candidate_group(scan_run, skip_count=0, mode="both"): """ Get the next group of candidates (all for the same main author). - Returns (main_autor, candidates_queryset, skip_count) or (None, None, 0) if no more pending. + Returns (main_autor, candidates_queryset, skip_count) or (None, None, 0) + if no more pending. Args: scan_run: The scan run to get candidates from skip_count: Number of main authors to skip (offset) + mode: Filter by scan_mode ("pbn", "general", or "both"). When "both", + PBN candidates are sorted before general (PBN is canonical). Returns: Tuple of (main_autor, candidates_queryset, current_skip_count) """ - # Get distinct main authors with pending candidates, ordered by priority then confidence - # We need to find distinct main_autor_ids in priority/confidence order + from django.db.models import Case, IntegerField, Value, When + + qs = DuplicateCandidate.objects.filter( + scan_run=scan_run, + status=DuplicateCandidate.Status.PENDING, + ) + if mode != "both": + qs = qs.filter(scan_mode=mode) + + # PBN-first ordering when mode=both distinct_main_autor_ids = ( - DuplicateCandidate.objects.filter( - scan_run=scan_run, - status=DuplicateCandidate.Status.PENDING, + qs.annotate( + mode_order=Case( + When(scan_mode="pbn", then=Value(0)), + When(scan_mode="general", then=Value(1)), + default=Value(2), + output_field=IntegerField(), + ) ) - .order_by("-priority", "-confidence_score", "main_autor_id") + .order_by("mode_order", "-priority", "-confidence_score", "main_autor_id") .values_list("main_autor_id", flat=True) .distinct() ) From d32be38fe63af65ff5f4005b18df8ffc23dc3af7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Fri, 1 May 2026 10:38:31 +0200 Subject: [PATCH 12/25] feat(deduplikator): scal_autorow_view backwards-compat + split ignore_autor/scientist - scal_autorow_view: accept main_autor_id/duplicate_autor_id (preferred) + legacy main_scientist_id/duplicate_scientist_id mapped via Scientist.rekord_w_bpp; view now calls scal_autora directly with Autor objects (the scal_autorow wrapper in utils/merge.py stays untouched for any external callers). - Split ignore endpoint: ignore_author -> ignore_scientist (writes IgnoredScientist), add new ignore_autor (writes IgnoredAuthor with FK->Autor). Reset endpoints renamed accordingly: reset_ignored_authors -> reset_ignored_scientists, plus new reset_ignored_autorzy. URL names + paths updated; template references adjusted in duplicate_authors.html. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../duplicate_authors.html | 4 +- .../tests/test_ignore_views.py | 62 +++++++ .../tests/test_scal_view.py | 70 ++++++++ src/deduplikator_autorow/urls.py | 14 +- src/deduplikator_autorow/views.py | 166 ++++++++++++++---- 5 files changed, 273 insertions(+), 43 deletions(-) create mode 100644 src/deduplikator_autorow/tests/test_ignore_views.py create mode 100644 src/deduplikator_autorow/tests/test_scal_view.py diff --git a/src/deduplikator_autorow/templates/deduplikator_autorow/duplicate_authors.html b/src/deduplikator_autorow/templates/deduplikator_autorow/duplicate_authors.html index 371c3544a..a3f486be2 100644 --- a/src/deduplikator_autorow/templates/deduplikator_autorow/duplicate_authors.html +++ b/src/deduplikator_autorow/templates/deduplikator_autorow/duplicate_authors.html @@ -318,7 +318,7 @@ target="_blank"> Zobacz listę -
+ {% csrf_token %} - {% if search_lastname %} - - Wyczyść - -
- Szukano: "{{ search_lastname }}" - {% if search_results_count is not None %} -
Znaleziono: {{ search_results_count }} autor{% if search_results_count != 1 %}ów{% endif %} - {% endif %} -
- {% endif %} - - - -
  • @@ -269,43 +238,35 @@
  • - +
  • 0 %}aria-expanded="true"{% endif %}> - Obejrz nie-duplikaty + Nie-duplikaty {% if not_duplicate_count > 0 %}({{ not_duplicate_count }}){% endif %}
    -

    Przejrzyj autorów oznaczonych jako nie będących duplikatami.

    - - Otwórz listę - -
    -
  • - - - {% if not_duplicate_count > 0 %} -
  • - - Zresetuj nie-duplikaty ({{ not_duplicate_count }}) - -
    -

    Wyczyść wszystkie oznaczenia nie-duplikatów.

    -

    - Obecnie: {{ not_duplicate_count }} oznaczonych -

    -
    - {% csrf_token %} - -
    + {% if not_duplicate_count > 0 %} +

    Autorzy oznaczeni jako nie będący duplikatami.

    +

    + Obecnie: {{ not_duplicate_count }} oznaczonych +

    + + Otwórz listę + +
    + {% csrf_token %} + +
    + {% else %} +

    Brak autorów oznaczonych jako nie-duplikaty.

    + {% endif %}
  • - {% endif %}
  • @@ -398,36 +359,94 @@ {% endif %} - {# Mode filter #} -
    -
    - Pokaż wyniki: - - - + {# Top bar: mode-filter po lewej, szybkie wyszukiwanie po prawej. #} + {# Filtr pewności (Wszyscy/Pewniaki/Słabe) NIE jest tutaj - dotyczy #} + {# tylko kandydatów aktualnego głównego autora i wisi przy #} + {# nagłówku 'Możliwe duplikaty'. #} + {# Top-bar pokazujemy tylko gdy mamy co filtrować — czyli mamy #} + {# głównego autora albo wynik wyszukiwania. Na ekranie postępu #} + {# skanowania ('running_scan and not glowny_autor') schowane. #} + {% with q_search=search_lastname %} + {% if glowny_autor or search_lastname %} +
    + + +
    + {% endif %} + {% endwith %} {% include "includes/pbn_freshness_warning.html" with custom_message="Analiza duplikatów opiera się na lokalnej kopii danych autorów z PBN. Wyniki mogą być nieaktualne." %}
    -

    Deduplikator Autorów PBN BETA

    +

    Deduplikator autorów

    {% if not search_lastname and scientist %} @@ -458,6 +477,17 @@

    Deduplikator Autorów PBN Następny autor

    + {% elif search_lastname and glowny_autor %} +
    + {% if search_has_prev %} + Poprzedni + {% endif %} + {% if search_has_next %} + Następny + {% endif %} +
    {% endif %}
    @@ -514,7 +544,22 @@

    Gratulacje!

    {% else %} {% if glowny_autor %}
    -

    Główny rekord autora{% if first_candidate %}{% if first_candidate.scan_mode == "pbn" %}PBN{% else %}OGÓLNY{% endif %}{% endif %}

    +

    + Główny rekord autora + {% if first_candidate %} + {% if first_candidate.scan_mode == "pbn" %} + + PBN + + {% else %} + + ogólny + + {% endif %} + {% endif %} +

    Imię i nazwisko:
    @@ -596,62 +641,116 @@

    Dyscypliny głównego autora (2022-2025):

    {% if glowne_publikacje %} -

    Publikacje głównego autora:{% if glowne_publikacje_year_range %} +

    {% if glowne_publikacje_count == 1 %}Publikacja{% else %}Publikacje{% endif %} głównego autora:{% if glowne_publikacje_year_range %} ({{ glowne_publikacje_year_range }}){% endif %}

    {% for publikacja in glowne_publikacje %}
    - + - {{ publikacja.tytul_oryginalny }} + {{ publikacja.opis_bibliograficzny_cache|truncatewords_html:30|safe }} - {% if publikacja.rok %} - ({{ publikacja.rok }}){% endif %} -
    - {{ publikacja.opis_bibliograficzny_cache|truncatewords:20 }} +
    {% endfor %}
    {% endif %}
    -
    -
    -

    Możliwe duplikaty ({{ duplikaty_z_publikacjami|length }})

    -
    - {% if duplikaty_z_publikacjami|length > 0 %} -
    -
    - - - Scalanie... 0/0 duplikatów - +
    + {# Wiersz 1: nagłówek + wskaźnik postępu + przyciski scalania. #} +
    +
    +

    + Możliwe duplikaty + {% if confidence_band != 'all' and duplikaty_z_publikacjami|length != candidates_total_for_main %} + ({{ duplikaty_z_publikacjami|length }} z {{ candidates_total_for_main }}) + {% else %} + ({{ duplikaty_z_publikacjami|length }}) + {% endif %} +

    + {% if duplikaty_z_publikacjami|length > 0 %} + {# Wskaźnik postępu - ukryty domyślnie, JS pokazuje go w trakcie scalania. #} + +
    +
    + + +
    +
    + {% endif %}
    -
    -
    - - + + {# Wiersz 2: filtr pewności per-autor — Wszyscy / Pewniaki / Słabe. #} + {# Liczniki dotyczą TYLKO kandydatów aktualnego głównego #} + {# autora; nie skanuje całej bazy. #} + {# Ukrywamy cały rząd gdy obie kategorie mają kandydatów #} + {# — ale gdy tylko jedna kategoria > 0, nie ma czego filtrować. #} + {% if candidates_high_for_main > 0 and candidates_low_for_main > 0 %} + {% endif %}
    {% for duplikat_data in duplikaty_z_publikacjami %} -
    +
    @@ -698,105 +797,154 @@

    Możliwe duplikaty ({{ duplik
    Powody podobieństwa:
    -
      +
      {% for powod in duplikat_data.analiza.powody_podobienstwa %} -
    • {{ powod }}
    • + + + {{ powod.text }} + {% empty %} -
    • Brak szczegółowych powodów
    • + + + Brak szczegółowych powodów + {% endfor %} -
    +

    -
    -
    - - - - - - Pokaż wyd. ciągłe - - - Pokaż wyd. zwarte - - {% if duplikat_data.candidate_id %} -
    - {% csrf_token %} - - -
    - {% else %} -
    - {% csrf_token %} - - -
    - {% endif %} - {% if duplikat_data.publikacje_count == 0 %} + {% endif %} + {% if duplikat_data.publikacje_count == 0 %} - {% endif %} + {% endif %} +
    +
    + + {# 3. SCALANIE — co zrobić, jeśli to duplikat #} +
    +
    + Scalanie +
    +
    + {% if glowny_autor_dyscypliny %} + + + {% endif %} + + +
    @@ -846,23 +994,19 @@
    Dyscypliny duplikatu (2022-2025)
    {% if duplikat_data.publikacje %} -
    Publikacje duplikatu: +
    {% if duplikat_data.publikacje_count == 1 %}Publikacja{% else %}Publikacje{% endif %} duplikatu: {% if duplikat_data.publikacje_year_range %} ({{ duplikat_data.publikacje_year_range }}){% endif %}
    {% for publikacja in duplikat_data.publikacje %}
    - + - {{ publikacja.tytul_oryginalny }} + {{ publikacja.opis_bibliograficzny_cache|truncatewords_html:25|safe }} - {% if publikacja.rok %} - ({{ publikacja.rok }}){% endif %} -
    - - {{ publikacja.opis_bibliograficzny_cache|truncatewords:15 }} +
    {% endfor %}
    @@ -965,8 +1109,25 @@
    Publikacje duplikatu: document.addEventListener('DOMContentLoaded', function() { adjustSidebarPosition(); relocateMessages(); // Move messages to respect sidebar layout + scrollToHashTarget(); // Po kliknięciu filtra pewności scroll-do-mozliwe-duplikaty }); + // Po nawigacji z fragmentem URL (np. ?confidence=high#mozliwe-duplikaty) + // przeglądarka domyślnie skoczy do elementu, ale sticky header BPP go + // schowa. Używamy oficjalnego helpera bpp.scrollToVisible, jak wymagane + // w CLAUDE.md. + function scrollToHashTarget() { + var hash = window.location.hash; + if (!hash || hash.length < 2) return; + var el = document.getElementById(hash.substring(1)); + if (!el) return; + if (window.bpp && typeof window.bpp.scrollToVisible === 'function') { + window.bpp.scrollToVisible(el); + } else { + el.scrollIntoView({ behavior: 'smooth', block: 'start' }); + } + } + // Adjust on window resize and scroll window.addEventListener('resize', adjustSidebarPosition); window.addEventListener('scroll', adjustSidebarPosition); @@ -995,10 +1156,10 @@
    Publikacje duplikatu: button.querySelector('.spinner').style.display = 'inline'; } - // Build request data + // Build request data — use Autor PKs directly (template provides Autor PKs). var requestData = { - 'main_scientist_id': mainAuthorId, - 'duplicate_scientist_id': duplicateAuthorId + 'main_autor_id': mainAuthorId, + 'duplicate_autor_id': duplicateAuthorId }; if (candidateId) { requestData['candidate_id'] = candidateId; @@ -1183,6 +1344,185 @@
    Publikacje duplikatu: e.preventDefault(); }); + // Autocomplete dla pola "Szukaj autora po nazwisku" - debounce 200ms, + // używa endpointu lastname_suggestions zwracającego top-10 nazwisk + // z pending DuplicateCandidate. Przy < 2 znakach lista jest czyszczona. + (function setupLastnameAutocomplete() { + var input = document.getElementById('search_lastname'); + var datalist = document.getElementById('lastname-suggestions'); + if (!input || !datalist) return; + + var debounceTimer = null; + var lastQuery = ''; + + function setOptions(names) { + while (datalist.firstChild) datalist.removeChild(datalist.firstChild); + names.forEach(function(name) { + var opt = document.createElement('option'); + opt.value = name; + datalist.appendChild(opt); + }); + } + + input.addEventListener('input', function() { + var q = input.value.trim(); + if (q.length < 2) { + setOptions([]); + lastQuery = ''; + return; + } + if (q === lastQuery) return; + lastQuery = q; + + clearTimeout(debounceTimer); + debounceTimer = setTimeout(function() { + fetch('{% url "deduplikator_autorow:lastname_suggestions" %}?q=' + encodeURIComponent(q), { + headers: { 'X-Requested-With': 'XMLHttpRequest' } + }) + .then(function(r) { return r.json(); }) + .then(function(data) { + if (data && Array.isArray(data.results)) { + setOptions(data.results); + } + }) + .catch(function(err) { + console.error('Autocomplete failed:', err); + }); + }, 200); + }); + })(); + + // Po usunięciu/scaleniu karty duplikatu — przelicz, czy "Scal wszystkie" + // mogą wrócić do stanu aktywnego (wszyscy pozostali kandydaci ≥ 50%). + // Działa przeciwnie też: jeśli ktoś usunie ostatni "dobry" wpis i zostały + // tylko niskoprocentowe, przyciski wracają w stan disabled. + var MIN_PEWNOSC_THRESHOLD = {{ MIN_PEWNOSC_DO_WYSWIETLENIA|default:50 }}; + function refreshMergeAllAvailability() { + var cards = document.querySelectorAll('[id^="duplicate-card-"]'); + if (cards.length === 0) return; + + var minPewnosc = 100; + var lowConfidence = []; + cards.forEach(function(card) { + var p = parseInt(card.dataset.pewnosc, 10); + if (isNaN(p)) return; + if (p < minPewnosc) minPewnosc = p; + if (p < MIN_PEWNOSC_THRESHOLD) { + lowConfidence.push((card.dataset.authorName || 'autor') + ' (' + p + '%)'); + } + }); + + var allowMergeAll = lowConfidence.length === 0; + var buttons = document.querySelectorAll('[data-merge-all]'); + var group = document.querySelector('[data-low-confidence-names]'); + + buttons.forEach(function(btn) { + if (allowMergeAll) { + btn.classList.remove('deduplikator-autorow__merge-all-btn--disabled'); + btn.removeAttribute('aria-disabled'); + } else { + btn.classList.add('deduplikator-autorow__merge-all-btn--disabled'); + btn.setAttribute('aria-disabled', 'true'); + } + }); + + if (group) { + if (allowMergeAll) { + group.removeAttribute('data-low-confidence-names'); + } else { + group.setAttribute('data-low-confidence-names', lowConfidence.join('||')); + } + } else if (!allowMergeAll && buttons.length > 0) { + // Brak wrappera (przyciski były od początku aktywne) — dorzuć dataset + // do najbliższego button-group, żeby alert mógł odczytać nazwiska. + var bg = buttons[0].closest('.button-group'); + if (bg) bg.setAttribute('data-low-confidence-names', lowConfidence.join('||')); + } + } + + // Event delegation for "Nie są duplikatami" - AJAX with fadeOut + document.addEventListener('click', function(e) { + var target = e.target.closest('[data-mark-not-duplicate]'); + if (!target) return; + e.preventDefault(); + + var kind = target.dataset.markNotDuplicate; + var duplicateAuthorId = target.dataset.duplicateAuthor; + var card = document.getElementById('duplicate-card-' + duplicateAuthorId); + + var url, payload; + if (kind === 'candidate') { + url = '{% url "deduplikator_autorow:mark_candidate_not_duplicate" %}'; + payload = { candidate_id: target.dataset.candidateId }; + } else { + url = '{% url "deduplikator_autorow:mark_non_duplicate" %}'; + payload = { scientist_pk: target.dataset.scientistPk }; + } + + target.disabled = true; + + $.ajax({ + url: url, + type: 'POST', + dataType: 'json', + headers: { + 'X-CSRFToken': '{{ csrf_token }}', + 'X-Requested-With': 'XMLHttpRequest' + }, + data: payload, + success: function(response) { + if (response && response.success) { + if (bppNotifications && bppNotifications.addMessage && response.message) { + bppNotifications.addMessage({ + cssClass: 'success', + text: response.message, + sound: false + }); + } + if (card) { + $(card).fadeOut(400, function() { + $(this).remove(); + var countElement = document.querySelector('.deduplikator-autorow__duplicates-title'); + if (countElement) { + var match = countElement.textContent.match(/\((\d+)\)/); + if (match) { + var newCount = parseInt(match[1]) - 1; + countElement.textContent = 'Możliwe duplikaty (' + newCount + ')'; + if (newCount === 0) { + setTimeout(function() { window.location.reload(); }, 500); + return; + } + } + } + refreshMergeAllAvailability(); + }); + } + } else { + target.disabled = false; + var msg = (response && response.message) || 'Operacja nie powiodła się.'; + if (bppNotifications && bppNotifications.addMessage) { + bppNotifications.addMessage({ cssClass: 'alert', text: msg, sound: true }); + } else { + alert(msg); + } + } + }, + error: function(xhr) { + target.disabled = false; + var msg = 'Błąd serwera podczas oznaczania jako nie-duplikat.'; + try { + var resp = xhr.responseJSON || JSON.parse(xhr.responseText); + if (resp && resp.message) msg = resp.message; + } catch (err) { /* ignore parse */ } + if (bppNotifications && bppNotifications.addMessage) { + bppNotifications.addMessage({ cssClass: 'alert', text: msg, sound: true }); + } else { + alert(msg); + } + } + }); + }); + // Event delegation for delete author (data-delete-author) document.addEventListener('click', function(e) { var target = e.target.closest('[data-delete-author]'); @@ -1218,10 +1558,34 @@
    Publikacje duplikatu: document.addEventListener('click', function(e) { var target = e.target.closest('[data-merge-all]'); if (!target) return; + e.preventDefault(); + + // Wyszarzony przycisk (są kandydaci z pewnością < 50%) -> komunikat, + // zamiast uruchamiać scalanie zbiorcze. + if (target.getAttribute('aria-disabled') === 'true') { + var group = target.closest('[data-low-confidence-names]'); + var namesAttr = group ? group.dataset.lowConfidenceNames : ''; + var names = namesAttr ? namesAttr.split('||') : []; + var listHtml = names.length + ? '\n\nAutorzy poniżej progu 50%:\n • ' + names.join('\n • ') + : ''; + alert( + 'Scalanie zbiorcze jest wyłączone dla tego głównego autora, ' + + 'ponieważ co najmniej jeden potencjalny duplikat ma pewność ' + + 'poniżej 50%.' + listHtml + '\n\n' + + 'Co możesz zrobić, żeby przyciski wróciły:\n' + + ' • Dodaj niepewnych autorów do "ignorowanych" (przycisk ' + + '"Ignoruj autora" w nagłówku),\n' + + ' • lub scal/odrzuć ich ręcznie (przyciski w sekcji ' + + '"Decyzja" / "Scalanie" przy każdej karcie).\n\n' + + 'Scalanie zbiorcze jest dostępne tylko, gdy WSZYSCY kandydaci ' + + 'mają pewność ≥ 50%.' + ); + return; + } var skipPbn = target.dataset.skipPbn === 'true'; handleMergeAll(skipPbn); - e.preventDefault(); }); function handleMergeAll(skipPbn) { @@ -1256,6 +1620,11 @@
    Publikacje duplikatu: } // Show progress, disable buttons + var progressCell = document.getElementById('merge-all-progress-cell'); + if (progressCell) { + progressCell.removeAttribute('hidden'); + progressCell.style.display = ''; + } document.getElementById('merge-all-progress').style.display = 'block'; document.getElementById('merge-all-btn').disabled = true; document.getElementById('merge-all-no-pbn-btn').disabled = true; @@ -1291,8 +1660,8 @@
    Publikacje duplikatu: type: 'GET', dataType: 'json', data: { - 'main_scientist_id': mainAuthorId, - 'duplicate_scientist_id': duplicateId, + 'main_autor_id': mainAuthorId, + 'duplicate_autor_id': duplicateId, 'skip_pbn': skipPbn }, complete: function(xhr, status) { @@ -1321,6 +1690,11 @@
    Publikacje duplikatu: function handleMergeAllError(response, duplicateId) { // Hide progress + var progressCell = document.getElementById('merge-all-progress-cell'); + if (progressCell) { + progressCell.setAttribute('hidden', ''); + progressCell.style.display = 'none'; + } document.getElementById('merge-all-progress').style.display = 'none'; // Re-enable main buttons diff --git a/src/deduplikator_autorow/views.py b/src/deduplikator_autorow/views.py index d027d7537..646588e96 100644 --- a/src/deduplikator_autorow/views.py +++ b/src/deduplikator_autorow/views.py @@ -35,6 +35,7 @@ znajdz_pierwszego_autora_z_duplikatami, ) from .utils.counters import get_latest_usable_scan +from .utils.reason_display import enrich_reasons # Minimalny próg pewności do wyświetlania duplikatów # Duplikaty z pewnością poniżej tego progu nie będą pokazywane @@ -172,6 +173,12 @@ def _build_context_from_candidate(candidate, glowny_autor): Rekord.objects.prace_autora(candidate.duplicate_autor) ) + # Display percent: znormalizowane 0..1 → 0..100, zaokrąglone i sklampowane. + # Surowy confidence_score może być < 0 lub > 100 i historycznie pokazywał + # użytkownikom wartości w rodzaju 140% — confidence_percent jest jedynym + # polem, które gwarantuje sensowny zakres do prezentacji. + pewnosc_display = max(0, min(100, round((candidate.confidence_percent or 0) * 100))) + return { "autor": candidate.duplicate_autor, "publikacje": publikacje, @@ -179,8 +186,8 @@ def _build_context_from_candidate(candidate, glowny_autor): "publikacje_year_range": year_range, "analiza": { "autor": candidate.duplicate_autor, - "pewnosc": candidate.confidence_score, - "powody_podobienstwa": candidate.reasons, + "pewnosc": pewnosc_display, + "powody_podobienstwa": enrich_reasons(candidate.reasons), }, "candidate_id": candidate.pk, # For marking as not duplicate } @@ -205,6 +212,14 @@ def duplicate_authors_view(request): # noqa: C901 if mode not in ("pbn", "general", "both"): mode = "both" + # Filter confidence band: all|high|low (default all). high=>=50%, low=<50%. + # Próg porównujemy do confidence_percent jako ułamka, bo display % jest + # liczone z confidence_percent * 100 z klampem. + confidence_band = request.GET.get("confidence", "all") + if confidence_band not in ("all", "high", "low"): + confidence_band = "all" + confidence_threshold_frac = MIN_PEWNOSC_DO_WYSWIETLENIA / 100.0 + # Common context not_duplicate_count = NotADuplicate.objects.count() ignored_authors_count = IgnoredScientist.objects.count() @@ -262,29 +277,27 @@ def duplicate_authors_view(request): # noqa: C901 return render(request, "deduplikator_autorow/duplicate_authors.html", context) # Count pending candidates - pending_count = DuplicateCandidate.objects.filter( + base_pending_qs = DuplicateCandidate.objects.filter( scan_run=completed_scan, status=DuplicateCandidate.Status.PENDING, - ).count() + ) + pending_count = base_pending_qs.count() context["pending_candidates_count"] = pending_count context["total_authors_with_duplicates"] = pending_count - context["pending_pbn_count"] = DuplicateCandidate.objects.filter( - scan_run=completed_scan, - status=DuplicateCandidate.Status.PENDING, - scan_mode="pbn", - ).count() - context["pending_general_count"] = DuplicateCandidate.objects.filter( - scan_run=completed_scan, - status=DuplicateCandidate.Status.PENDING, - scan_mode="general", + context["pending_pbn_count"] = base_pending_qs.filter(scan_mode="pbn").count() + context["pending_general_count"] = base_pending_qs.filter( + scan_mode="general" ).count() + context["confidence_band"] = confidence_band # Handle search by lastname search_lastname = request.GET.get("search_lastname", "").strip() context["search_lastname"] = search_lastname if search_lastname: - # Search within stored candidates + # Search within stored candidates - confidence_band celowo NIE filtruje + # wyboru głównego autora (filtr per-autor stosujemy niżej, na liście + # candidates_for_author). candidates = ( DuplicateCandidate.objects.filter( scan_run=completed_scan, @@ -302,9 +315,24 @@ def duplicate_authors_view(request): # noqa: C901 ) if candidates.exists(): - first_candidate = candidates.first() - glowny_autor = first_candidate.main_autor + search_author_ids = list( + candidates.values_list("main_autor", flat=True) + .distinct() + .order_by("main_autor") + ) + try: + skip_count = int(request.GET.get("skip_count", 0)) + except (ValueError, TypeError): + skip_count = 0 + if skip_count >= len(search_author_ids): + skip_count = 0 + glowny_autor_id = search_author_ids[skip_count] + glowny_autor = Autor.objects.get(pk=glowny_autor_id) candidates_for_author = candidates.filter(main_autor=glowny_autor) + context["skip_count"] = skip_count + context["search_total_authors"] = len(search_author_ids) + context["search_has_prev"] = skip_count > 0 + context["search_has_next"] = skip_count < len(search_author_ids) - 1 else: glowny_autor = None candidates_for_author = DuplicateCandidate.objects.none() @@ -315,12 +343,41 @@ def duplicate_authors_view(request): # noqa: C901 except (ValueError, TypeError): skip_count = 0 - # Get next author with pending duplicates using offset + # Get next author with pending duplicates using offset. + # confidence_band NIE jest tu przekazywane — chcemy iterować po + # WSZYSTKICH głównych autorach niezależnie od pewności ich kandydatów, + # filtr stosujemy niżej tylko na widocznym podzbiorze. glowny_autor, candidates_for_author, skip_count = _get_next_candidate_group( - completed_scan, skip_count=skip_count, mode=mode + completed_scan, + skip_count=skip_count, + mode=mode, ) context["skip_count"] = skip_count + # Filter per-author by confidence band (NOT main author selection). + # Liczniki "X / Y" oraz per-band wyliczamy zanim podstawimy filtr. + if glowny_autor: + candidates_total_for_main = candidates_for_author.count() + candidates_high_for_main = candidates_for_author.filter( + confidence_percent__gte=confidence_threshold_frac + ).count() + candidates_low_for_main = candidates_total_for_main - candidates_high_for_main + else: + candidates_total_for_main = 0 + candidates_high_for_main = 0 + candidates_low_for_main = 0 + if confidence_band == "high": + candidates_for_author = candidates_for_author.filter( + confidence_percent__gte=confidence_threshold_frac + ) + elif confidence_band == "low": + candidates_for_author = candidates_for_author.filter( + confidence_percent__lt=confidence_threshold_frac + ) + context["candidates_total_for_main"] = candidates_total_for_main + context["candidates_high_for_main"] = candidates_high_for_main + context["candidates_low_for_main"] = candidates_low_for_main + if not glowny_autor: if pending_count == 0: messages.info( @@ -347,6 +404,21 @@ def duplicate_authors_view(request): # noqa: C901 candidates_for_author.first() if candidates_for_author else None ) + # "Scal wszystkie" jest aktywne tylko wtedy, gdy KAŻDY kandydat ma pewność + # ≥ MIN_PEWNOSC_DO_WYSWIETLENIA. Przy słabych trafieniach przyciski + # renderujemy w stanie wyszarzonym i klik pokazuje komunikat tłumaczący, + # co zrobić dalej (lista nazwisk z niską pewnością). + low_confidence_names = [ + f"{d['autor']} ({d['analiza']['pewnosc']}%)" + for d in duplikaty_z_publikacjami + if d["analiza"]["pewnosc"] < MIN_PEWNOSC_DO_WYSWIETLENIA + ] + context["allow_merge_all"] = ( + bool(duplikaty_z_publikacjami) and not low_confidence_names + ) + context["low_confidence_names"] = low_confidence_names + context["MIN_PEWNOSC_DO_WYSWIETLENIA"] = MIN_PEWNOSC_DO_WYSWIETLENIA + # Get main author's publications and disciplines context["glowny_autor_dyscypliny"] = ( Autor_Dyscyplina.objects.filter( @@ -433,6 +505,18 @@ def scal_autorow_view(request): ) if not main_autor_id or not duplicate_autor_id: + # Sygnalizujemy do Rollbar — to nie powinno się zdarzać przy poprawnym + # wywołaniu z UI; raczej oznacza błąd JS-a lub niespójne dane (np. + # scientist_id wskazujący na rekord, którego rekord_w_bpp == None). + try: + raise ValueError( + "scal_autorow_view: missing required params after resolution. " + f"GET={dict(request.GET)} POST_keys={list(request.POST.keys())} " + f"resolved main={main_autor_id} duplicate={duplicate_autor_id}" + ) + except ValueError: + traceback.print_exc() + rollbar.report_exc_info(sys.exc_info()) return JsonResponse( { "success": False, @@ -495,37 +579,44 @@ def mark_non_duplicate(request): Przyjmuje parametry: - scientist_pk: Primary key Scientist do zapisania jako nie-duplikat - Zapisuje w modelu NotADuplicate i przekierowuje do następnego autora. + Zwraca JSON dla AJAX (X-Requested-With), w przeciwnym razie redirect. """ + is_ajax = request.headers.get("X-Requested-With") == "XMLHttpRequest" scientist_pk = request.POST.get("scientist_pk") - if not scientist_pk: - messages.error(request, "Brak wymaganego parametru: scientist_pk") + def _respond(success, message, status=200, level="success"): + if is_ajax: + return JsonResponse({"success": success, "message": message}, status=status) + if level == "info": + messages.info(request, message) + elif success: + messages.success(request, message) + else: + messages.error(request, message) return redirect("deduplikator_autorow:duplicate_authors") + if not scientist_pk: + return _respond(False, "Brak wymaganego parametru: scientist_pk", status=400) + try: - # Sprawdź czy Scientist istnieje autor = Autor.objects.get(pk=scientist_pk) - # Zapisz jako nie-duplikat (get_or_create zapobiega duplikatom) not_duplicate, created = NotADuplicate.objects.update_or_create( autor=autor, defaults=dict(created_by=request.user) ) if created: - messages.success(request, f"Autor {autor} oznaczony jako nie-duplikat.") - else: - messages.info( - request, f"Autor {autor} był już oznaczony jako nie-duplikat." - ) + return _respond(True, f"Autor {autor} oznaczony jako nie-duplikat.") + return _respond( + True, f"Autor {autor} był już oznaczony jako nie-duplikat.", level="info" + ) except Autor.DoesNotExist: - messages.error(request, "Nie znaleziono autora o podanym ID.") + return _respond(False, "Nie znaleziono autora o podanym ID.", status=404) except Exception as e: - messages.error(request, f"Błąd podczas oznaczania autora: {str(e)}") - - # Przekieruj do następnego autora z duplikatami - return redirect("deduplikator_autorow:duplicate_authors") + traceback.print_exc() + rollbar.report_exc_info(sys.exc_info()) + return _respond(False, f"Błąd podczas oznaczania autora: {str(e)}", status=500) @group_required(GR_WPROWADZANIE_DANYCH) @@ -642,41 +733,75 @@ def ignore_autor(request): return redirect("deduplikator_autorow:duplicate_authors") +def _trigger_rescan_after_reset(request, reset_label): + """Próbuje uruchomić nowe skanowanie po resecie list ignorowanych/nie-duplikatów. + + Reset zmienia zbiór wykluczeń, więc cache kandydatów (DuplicateCandidate) + przestaje być spójny z tym, co użytkownik widzi w UI. Bez rescanu mogą + pojawiać się duplikaty, które po reset-cie powinny zniknąć (lub odwrotnie: + brakować takich, które wcześniej były ignorowane). Wywołujemy delay() + w trybie best-effort — jeżeli scan już biegnie albo dane PBN są stare, + informujemy użytkownika ale nie blokujemy operacji resetu. + """ + from .tasks import scan_for_duplicates + + if get_running_scan(): + messages.info( + request, + f"{reset_label}. Skanowanie duplikatów jest już w trakcie — " + "wyniki uwzględnią reset po jego zakończeniu.", + ) + return + + pbn_data_fresh, pbn_stale_message, _ = is_pbn_people_data_fresh() + if not pbn_data_fresh: + messages.warning( + request, + f"{reset_label}. Nie udało się automatycznie uruchomić skanowania " + f"({pbn_stale_message}); uruchom je ręcznie po pobraniu danych PBN.", + ) + return + + scan_for_duplicates.delay(user_id=request.user.pk) + messages.success( + request, + f"{reset_label}. Uruchomiono nowe skanowanie duplikatów w tle — " + "odśwież stronę za chwilę, aby zobaczyć postęp.", + ) + + @group_required(GR_WPROWADZANIE_DANYCH) @require_http_methods(["POST"]) def reset_ignored_scientists(request): - """ - Remove all IgnoredScientist (PBN) markings. - """ + """Remove all IgnoredScientist (PBN) markings and re-trigger scan.""" count = IgnoredScientist.objects.count() IgnoredScientist.objects.all().delete() - messages.success(request, f"Zresetowano {count} ignorowanych autorów (PBN).") + _trigger_rescan_after_reset( + request, f"Zresetowano {count} ignorowanych autorów (PBN)" + ) return redirect("deduplikator_autorow:duplicate_authors") @group_required(GR_WPROWADZANIE_DANYCH) @require_http_methods(["POST"]) def reset_ignored_autorzy(request): - """ - Remove all IgnoredAuthor (BPP) markings. - """ + """Remove all IgnoredAuthor (BPP) markings and re-trigger scan.""" count = IgnoredAuthor.objects.count() IgnoredAuthor.objects.all().delete() - messages.success(request, f"Zresetowano {count} ignorowanych autorów (BPP).") + _trigger_rescan_after_reset( + request, f"Zresetowano {count} ignorowanych autorów (BPP)" + ) return redirect("deduplikator_autorow:duplicate_authors") @group_required(GR_WPROWADZANIE_DANYCH) def reset_not_duplicates(request): - """ - Widok do resetowania (usuwania) wszystkich rekordów NotADuplicate. - """ + """Widok do resetowania (usuwania) wszystkich rekordów NotADuplicate.""" if request.method == "POST": count = NotADuplicate.objects.count() NotADuplicate.objects.all().delete() - messages.success( - request, - f"Zresetowano {count} autorów oznaczonych jako nie-duplikat.", + _trigger_rescan_after_reset( + request, f"Zresetowano {count} autorów oznaczonych jako nie-duplikat" ) return redirect("deduplikator_autorow:duplicate_authors") @@ -886,7 +1011,13 @@ def _get_pending_candidates_for_main_autor(main_autor_id, scan_run): ) -def _get_next_candidate_group(scan_run, skip_count=0, mode="both"): +def _get_next_candidate_group( + scan_run, + skip_count=0, + mode="both", + confidence_band="all", + confidence_threshold_frac=0.5, +): """ Get the next group of candidates (all for the same main author). Returns (main_autor, candidates_queryset, skip_count) or (None, None, 0) @@ -897,6 +1028,9 @@ def _get_next_candidate_group(scan_run, skip_count=0, mode="both"): skip_count: Number of main authors to skip (offset) mode: Filter by scan_mode ("pbn", "general", or "both"). When "both", PBN candidates are sorted before general (PBN is canonical). + confidence_band: "all" / "high" / "low". high = confidence_percent + >= threshold; low = strictly below threshold. + confidence_threshold_frac: próg jako ułamek 0..1 (np. 0.5 dla 50%). Returns: Tuple of (main_autor, candidates_queryset, current_skip_count) @@ -909,6 +1043,10 @@ def _get_next_candidate_group(scan_run, skip_count=0, mode="both"): ) if mode != "both": qs = qs.filter(scan_mode=mode) + if confidence_band == "high": + qs = qs.filter(confidence_percent__gte=confidence_threshold_frac) + elif confidence_band == "low": + qs = qs.filter(confidence_percent__lt=confidence_threshold_frac) # Annotate then iterate to dedupe in stable order. PostgreSQL's # DISTINCT + ORDER BY semantics require ordering columns in SELECT, @@ -958,20 +1096,61 @@ def _get_next_candidate_group(scan_run, skip_count=0, mode="both"): return main_autor, candidates, skip_count +@group_required(GR_WPROWADZANIE_DANYCH) +def lastname_suggestions(request): + """Autocomplete dla wyszukiwarki nazwisk w deduplikatorze. + + Zwraca top-10 unikalnych nazwisk autorów-głównych z PENDING-ujących + DuplicateCandidate filtrowanych po prefiksie. Bez aktywnego skanu + zwraca pustą listę. Wykorzystywane przez datalist na pasku górnym. + """ + q = (request.GET.get("q") or "").strip() + if not q or len(q) < 2: + return JsonResponse({"results": []}) + + completed_scan = get_latest_usable_scan() + if not completed_scan: + return JsonResponse({"results": []}) + + nazwiska = ( + DuplicateCandidate.objects.filter( + scan_run=completed_scan, + status=DuplicateCandidate.Status.PENDING, + main_autor__nazwisko__istartswith=q, + ) + .values_list("main_autor__nazwisko", flat=True) + .distinct() + .order_by("main_autor__nazwisko")[:10] + ) + return JsonResponse({"results": list(nazwiska)}) + + @group_required(GR_WPROWADZANIE_DANYCH) @require_http_methods(["POST"]) def mark_candidate_not_duplicate(request): """ Mark a DuplicateCandidate as not a duplicate. + + Returns JSON when called via AJAX (X-Requested-With: XMLHttpRequest), + otherwise redirects. """ from django.utils import timezone + is_ajax = request.headers.get("X-Requested-With") == "XMLHttpRequest" candidate_id = request.POST.get("candidate_id") - if not candidate_id: - messages.error(request, "Brak wymaganego parametru: candidate_id") + def _respond(success, message, status=200): + if is_ajax: + return JsonResponse({"success": success, "message": message}, status=status) + if success: + messages.success(request, message) + else: + messages.error(request, message) return redirect("deduplikator_autorow:duplicate_authors") + if not candidate_id: + return _respond(False, "Brak wymaganego parametru: candidate_id", status=400) + try: candidate = DuplicateCandidate.objects.get(pk=candidate_id) candidate.status = DuplicateCandidate.Status.NOT_DUPLICATE @@ -979,19 +1158,20 @@ def mark_candidate_not_duplicate(request): candidate.reviewed_by = request.user candidate.save() - # Also mark the duplicate author in NotADuplicate (existing model) NotADuplicate.objects.update_or_create( autor=candidate.duplicate_autor, defaults={"created_by": request.user} ) - messages.success( - request, + return _respond( + True, f"Autor {candidate.duplicate_autor_name} oznaczony jako nie-duplikat.", ) except DuplicateCandidate.DoesNotExist: - messages.error(request, "Nie znaleziono kandydata o podanym ID.") + return _respond(False, "Nie znaleziono kandydata o podanym ID.", status=404) except Exception as e: - messages.error(request, f"Błąd podczas oznaczania kandydata: {str(e)}") - - return redirect("deduplikator_autorow:duplicate_authors") + traceback.print_exc() + rollbar.report_exc_info(sys.exc_info()) + return _respond( + False, f"Błąd podczas oznaczania kandydata: {str(e)}", status=500 + ) From 3cbbe011ceb7fda48563f294bfbc6eb847a312ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Sat, 2 May 2026 11:53:52 +0200 Subject: [PATCH 18/25] feat(autocomplete): log Autor creation via autocomplete as Django admin LogEntry When a new author is created through the autocomplete 'create' dialog, a LogEntry (ADDITION) is now recorded in Django admin history with change_message='Utworzono z formularza autocomplete', making it possible to trace who created the author and from where. --- .../test_autocomplete_authors.py | 27 ++++++++++++++++--- src/bpp/views/autocomplete/authors.py | 19 ++++++++++++- 2 files changed, 42 insertions(+), 4 deletions(-) diff --git a/src/bpp/tests/test_autocomplete/test_autocomplete_authors.py b/src/bpp/tests/test_autocomplete/test_autocomplete_authors.py index b2c5cc003..8deefa593 100644 --- a/src/bpp/tests/test_autocomplete/test_autocomplete_authors.py +++ b/src/bpp/tests/test_autocomplete/test_autocomplete_authors.py @@ -20,7 +20,6 @@ ) - def test_dyscyplina_naukowa_przypisanie_autocomplete( app, autor_jan_kowalski, dyscyplina1, dyscyplina2, rok ): @@ -75,7 +74,6 @@ def test_dyscyplina_naukowa_przypisanie_autocomplete( assert res.json["results"][0]["text"] == "memetyka stosowana" - def test_dyscyplina_naukowa_przypisanie_autocomplete_brak_autora( app, ): @@ -90,7 +88,6 @@ def test_dyscyplina_naukowa_przypisanie_autocomplete_brak_autora( assert res.json["results"][0]["text"] == "Podaj autora" - def test_dyscyplina_naukowa_przypisanie_autocomplete_brak_drugiej( app, autor_jan_kowalski, dyscyplina1, dyscyplina2, rok ): @@ -133,6 +130,30 @@ def autocomplete(s): assert Autor.objects.first().imiona == "Baz Quux" +@pytest.mark.django_db +def test_AutorAutocomplete_create_object_creates_log_entry(rf, admin_user, db): + from django.contrib.admin.models import ADDITION, LogEntry + from django.contrib.contenttypes.models import ContentType + + autor_count_before = Autor.objects.count() + + ac = AutorAutocomplete() + ac.request = rf.post("/", data={"text": "Kowalski Jan"}) + ac.request.user = admin_user + + obj = ac.create_object("Kowalski Jan") + + assert obj.pk != -1 + assert Autor.objects.count() == autor_count_before + 1 + + ct = ContentType.objects.get_for_model(Autor) + log = LogEntry.objects.get( + content_type=ct, object_id=str(obj.pk), action_flag=ADDITION + ) + assert log.user == admin_user + assert "autocomplete" in log.change_message + + @pytest.mark.django_db def test_Status_KorektyAutocomplete(statusy_korekt): """Test status korekty autocomplete filtering.""" diff --git a/src/bpp/views/autocomplete/authors.py b/src/bpp/views/autocomplete/authors.py index 1cea8c001..0373b49a6 100644 --- a/src/bpp/views/autocomplete/authors.py +++ b/src/bpp/views/autocomplete/authors.py @@ -94,10 +94,27 @@ class AutorAutocomplete(GroupRequiredMixin, AutorAutocompleteBase): def create_object(self, text): try: - return Autor.objects.create_from_string(text) + obj = Autor.objects.create_from_string(text) except ValueError: return self.err + from django.contrib.admin.models import ADDITION, LogEntry + from django.contrib.contenttypes.models import ContentType + + try: + LogEntry.objects.create( + user_id=self.request.user.pk, + content_type_id=ContentType.objects.get_for_model(Autor).pk, + object_id=str(obj.pk), + object_repr=str(obj)[:200], + action_flag=ADDITION, + change_message="Utworzono z formularza autocomplete", + ) + except (AttributeError, TypeError): + pass + + return obj + class PublicAutorAutocomplete(AutorAutocompleteBase): """Public autocomplete for authors (no create, no PBN/MNISW markers).""" From a371624f3128fc2f940cd121df897417589214c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Sat, 2 May 2026 17:47:33 +0200 Subject: [PATCH 19/25] =?UTF-8?q?fix(deduplikator=5Fautorow):=20UI=20popra?= =?UTF-8?q?wki=20cz.=202=20=E2=80=94=20CSS=20loading,=20layout,=20publikac?= =?UTF-8?q?je,=20nie-duplikaty?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Napraw ładowanie CSS: block extra_css nie istniał w hierarchii template'ów (bare.html->base.html), CSS nigdy się nie ładował. Zmieniono na {% block extrahead %} z {{ block.super }}. - Bold publikacji: selektor .callout .deduplikator-autorow__publication-item a (spec. 0,2,1) wygrywa z Foundation .callout a:not(.close-button) (0,1,1) - Lista publikacji: usunięte kolorowe border-left, obramowanie i padding — teraz zwykła lista tekstowa - Top bar: usunięta etykieta 'Pokaż wyniki:', same przyciski trybu po lewej, wyszukiwarka po prawej (space-between) - Przycisk 'Szukaj': border-radius: 0 po prawej gdy widoczny przycisk 'X' (czyszczenie wyszukiwania) - Nie-duplikaty: licznik w sidebarze aktualizuje się po AJAX-owym oznaczeniu kandydata jako nie-duplikat (span#not-duplicate-count + JS increment) - Empty state: gdy search_lastname aktywny i brak wyników — 'Nie znaleziono takich autorów' zamiast 'Gratulacje' --- .../scss/deduplikator_autorow.scss | 54 +++++++++---------- .../duplicate_authors.html | 31 +++++++++-- 2 files changed, 51 insertions(+), 34 deletions(-) diff --git a/src/deduplikator_autorow/static/deduplikator_autorow/scss/deduplikator_autorow.scss b/src/deduplikator_autorow/static/deduplikator_autorow/scss/deduplikator_autorow.scss index 271550d39..4d3ca06e1 100644 --- a/src/deduplikator_autorow/static/deduplikator_autorow/scss/deduplikator_autorow.scss +++ b/src/deduplikator_autorow/static/deduplikator_autorow/scss/deduplikator_autorow.scss @@ -263,6 +263,16 @@ color: #666; } +.deduplikator-autorow__search-btn-flat-right { + border-top-right-radius: 0; + border-bottom-right-radius: 0; +} + +.deduplikator-autorow__search-btn-flat-right + .input-group-button .button { + border-top-left-radius: 0; + border-bottom-left-radius: 0; +} + // ============================================================================= // DISCIPLINE TABLE // ============================================================================= @@ -317,25 +327,21 @@ .deduplikator-autorow__publication-list { max-height: 300px; overflow-y: auto; - border: 1px solid #e1e1e1; - padding: 10px; + padding: 0; } .deduplikator-autorow__publication-list--short { max-height: 250px; - background-color: #f9f9f9; } .deduplikator-autorow__publication-item { - margin-bottom: 8px; - padding: 5px; - border-left: 3px solid #1779ba; + margin-bottom: 4px; + padding: 0; } .deduplikator-autorow__publication-item--duplicate { - margin-bottom: 6px; - padding: 3px; - border-left: 2px solid #8a8a8a; + margin-bottom: 2px; + padding: 0; } .deduplikator-autorow__publication-link { @@ -564,23 +570,19 @@ margin: 1em 0; } - // Top bar — filtr trybu po lewej, szybkie wyszukiwanie po prawej. - // Wymuszamy jeden wiersz (nowrap); search ma flex 1 + min-width: 0, - // żeby mógł zwężać się poniżej intrinsic width na wąskich ekranach, - // zamiast wyskakiwać do nowej linii i rozciągać się na 100% szerokości. + // Top bar — przyciski trybu po lewej, wyszukiwarka po prawej. &__top-bar { display: flex; align-items: center; - justify-content: flex-start; + justify-content: space-between; gap: 1em; margin: 1em 0; flex-wrap: nowrap; } &__top-search { - flex: 1 1 280px; - min-width: 0; - max-width: 420px; + flex: 0 1 auto; + min-width: 200px; margin: 0; } @@ -694,18 +696,12 @@ // Opisy bibliograficzne renderują / wokół tytułów — to jest OK, // tytuł ma być boldem. Problem: Foundation daje .callout a:not(.close-button) // { font-weight: bolder }, więc cały tekst w wewnątrz .callout jest - // bold. Resetujemy font-weight na w obrębie itemów publikacji, ale - // zostawiamy / z ich domyślnym bold, żeby tytuł nadal był - // wytłuszczony. - &__publication-list, - &__publication-item, - &__publication-item--duplicate, - &__publication-description { - font-weight: normal; - } - - &__publication-item a, - &__publication-item--duplicate a { + // bold. Resetujemy font-weight na w obrębie itemów publikacji z + // wyższą specyficznością niż .callout a:not(.close-button), żeby wygrać + // kaskadę. Zostawiamy / z ich domyślnym bold, żeby tytuł nadal + // był wytłuszczony. + .callout &__publication-item a, + .callout &__publication-item--duplicate a { font-weight: normal; } diff --git a/src/deduplikator_autorow/templates/deduplikator_autorow/duplicate_authors.html b/src/deduplikator_autorow/templates/deduplikator_autorow/duplicate_authors.html index 426a73bd5..1ac032b10 100644 --- a/src/deduplikator_autorow/templates/deduplikator_autorow/duplicate_authors.html +++ b/src/deduplikator_autorow/templates/deduplikator_autorow/duplicate_authors.html @@ -1,7 +1,8 @@ {% extends "base.html" %} {% load static %} -{% block extra_css %} +{% block extrahead %} +{{ block.super }} {% endblock %} @@ -241,13 +242,13 @@
  • 0 %}aria-expanded="true"{% endif %}> - Nie-duplikaty {% if not_duplicate_count > 0 %}({{ not_duplicate_count }}){% endif %} + Nie-duplikaty {% if not_duplicate_count > 0 %}({{ not_duplicate_count }}){% else %}{% endif %}
    {% if not_duplicate_count > 0 %}

    Autorzy oznaczeni jako nie będący duplikatami.

    - Obecnie: {{ not_duplicate_count }} oznaczonych + Obecnie: {{ not_duplicate_count }} oznaczonych

    - Pokaż wyniki:
    - +
    {% if q_search %}
    @@ -529,6 +529,15 @@

    Skanowanie w toku...

    Strona odświeży się automatycznie po zakończeniu skanowania.

    {% elif not glowny_autor %} + {% if search_lastname %} +
    + {% else %}

    Gratulacje!

    Wszystkie duplikaty zostały już przetworzone.

    @@ -541,6 +550,7 @@

    Gratulacje!

    + {% endif %} {% else %} {% if glowny_autor %}
    @@ -1479,6 +1489,17 @@
    {% if duplikat_data.publikacje_c sound: false }); } + {# Aktualizuj licznik nie-duplikatów w sidebarze #} + var ndBadge = document.getElementById('not-duplicate-count'); + var ndText = document.getElementById('not-duplicate-count-text'); + if (ndBadge) { + var ndCount = parseInt(ndBadge.dataset.count || '0') + 1; + ndBadge.dataset.count = ndCount; + ndBadge.textContent = ndCount; + } + if (ndText) { + ndText.textContent = (parseInt(ndText.textContent) || 0) + 1; + } if (card) { $(card).fadeOut(400, function() { $(this).remove(); From 12ff646876f4246ce84f6b71bd6bc7e42382aafb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Sat, 2 May 2026 18:02:10 +0200 Subject: [PATCH 20/25] =?UTF-8?q?feat(deduplikator=5Fautorow):=20hard=20re?= =?UTF-8?q?jection=20roz=C5=82=C4=85cznych=20imion,=20ORCID=20w=20XLSX,=20?= =?UTF-8?q?naprawa=20top=5Fbar=20HTML,=20testy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Hard reject kandydatów z rozłącznymi imionami (analysis_meta + analysis) - Kolumny ORCID i PBN URL w eksporcie XLSX - Endpoint lastname-suggestions do autouzupełniania - reason_display.py — utils do wyświetlania powodów duplikacji - Naprawa osieroconego
  • w top_bar.html (djlint H025) - Zmiana etykiety 'deduplikator autorów PBN' → 'deduplikator autorów' - Dodanie 'deduplikator autorów' do menu narzędzia - .gitignore: .grunt-build-stamp - CLAUDE.md: dodano reguły komentarzy Django i uv run - Nowe testy: merge_all_refresh, reasons_display, xlsx_orcid_and_pbn_url --- .gitignore | 2 + CLAUDE.md | 16 ++ ...duplikator-autorow-ui-overhaul.feature.rst | 28 +++ .../tests/test_analysis_meta.py | 164 ++++++++++++++- .../tests/test_merge_all_refresh.py | 194 ++++++++++++++++++ .../tests/test_reasons_display.py | 105 ++++++++++ .../tests/test_xlsx_export.py | 36 ++-- .../tests/test_xlsx_orcid_and_pbn_url.py | 108 ++++++++++ src/deduplikator_autorow/urls.py | 5 + src/deduplikator_autorow/utils/analysis.py | 67 +++++- .../utils/analysis_meta.py | 113 +++++++--- src/deduplikator_autorow/utils/export.py | 58 ++++-- .../utils/reason_display.py | 73 +++++++ src/django_bpp/templates/top_bar.html | 18 +- 14 files changed, 905 insertions(+), 82 deletions(-) create mode 100644 src/bpp/newsfragments/+deduplikator-autorow-ui-overhaul.feature.rst create mode 100644 src/deduplikator_autorow/tests/test_merge_all_refresh.py create mode 100644 src/deduplikator_autorow/tests/test_reasons_display.py create mode 100644 src/deduplikator_autorow/tests/test_xlsx_orcid_and_pbn_url.py create mode 100644 src/deduplikator_autorow/utils/reason_display.py diff --git a/.gitignore b/.gitignore index 3dceac2d7..3f59bd0ee 100644 --- a/.gitignore +++ b/.gitignore @@ -175,6 +175,8 @@ dump.rdb .worktrees/ .claude/ +.grunt-build-stamp + # Local TODO / audit notes (not committed) TODO-*.txt TODO-*.md diff --git a/CLAUDE.md b/CLAUDE.md index 9b5291de7..99272aca9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -25,6 +25,22 @@ management system built with Django. Python >=3.10,<3.15. - Public frontend (Foundation CSS): monochrome Foundation-Icons (``) - Django admin (`templates/admin/`): use emoji (no Foundation Icons) +- **Django template comments `{# ... #}` są jedno-liniowe — KAZDA LINIA + MUSI mieć własne otwarcie `{#` i zamknięcie `#}` na tej samej linii.** + Po `\n` w środku komentarza parser przestaje go widzieć i tekst wycieka + do wyrenderowanego HTML-u. Powtarzający się błąd. Reguła: + - ❌ ZABRONIONE wieloliniowe komentarze typu: + ```django + {# linia 1 + linia 2 #} + ``` + - ✅ ZAWSZE każda linia z osobnym `{# ... #}`: + ```django + {# linia 1 #} + {# linia 2 #} + ``` + - Alternatywa dla bloków: `{% comment %}...{% endcomment %}` (też OK, + ale per-line `{# #}` jest preferowane przez użytkownika). ## Python and Django Execution diff --git a/src/bpp/newsfragments/+deduplikator-autorow-ui-overhaul.feature.rst b/src/bpp/newsfragments/+deduplikator-autorow-ui-overhaul.feature.rst new file mode 100644 index 000000000..cc19ee26b --- /dev/null +++ b/src/bpp/newsfragments/+deduplikator-autorow-ui-overhaul.feature.rst @@ -0,0 +1,28 @@ +Deduplikator autorów: gruntowna przebudowa UI. Tytuł i pozycje +menu uproszczone z "Deduplikator autorów PBN" na "Deduplikator +autorów" (bez znacznika BETA), wpis dodany dodatkowo do podmenu +"Operacje". Tryb skanowania (PBN/ogólny) prezentowany jest jako +kolorowy badge przy "Główny rekord autora", filtr "Pokaż wyniki" +zmieniony z radio-buttonów na poziomy button-group. + +Przyciski na karcie każdego potencjalnego duplikatu pogrupowane +w trzy logiczne sekcje: Podgląd (otwórz wyd. ciągłe/zwarte, +redagowanie, stronę główną, PBN), Decyzja ("Nie jest duplikatem +głównego autora", usuń autora bez publikacji), Scalanie (cztery +warianty scalania). Przyciski "Scal + ustaw dyscyplinę" oraz +"Scal + ustaw subdyscyplinę" są ukryte, gdy główny autor nie ma +żadnej dyscypliny. + +Powody podobieństwa renderowane są jako kolorowe chipy z ikonami +Foundation, z tonami match/info/weak/warn dobranymi do siły +przesłanki. Procent pewności jest sklampowany do zakresu 0–100% +(wcześniej widoczne były wartości typu 140% wynikające z surowego +score). + +Naprawione: oznaczenie autora jako nie-duplikat (przycisk +"Nie jest duplikatem głównego autora") wykonuje się teraz przez +AJAX z fadeOut karty, zamiast przeładowywać widok i przeskakiwać +do kolejnego głównego autora. Naprawiono też "Scal wszystkie", +który dla kandydatów z trybu ogólnego zwracał błąd 400 (JS +wysyłał ``main_scientist_id`` zamiast ``main_autor_id``); brakujące +parametry trafiają teraz dodatkowo do Rollbara. diff --git a/src/deduplikator_autorow/tests/test_analysis_meta.py b/src/deduplikator_autorow/tests/test_analysis_meta.py index e436dcdd3..e4273250c 100644 --- a/src/deduplikator_autorow/tests/test_analysis_meta.py +++ b/src/deduplikator_autorow/tests/test_analysis_meta.py @@ -37,7 +37,8 @@ def test_identyczne_orcid_dodaje_50(): def test_rozne_orcid_odejmuje_50(): - # Różne nazwiska/imiona, żeby ORCID był dominującym sygnałem. + # Identyczne imiona (żeby hard-rejection nie zadziałał i ORCID-mismatch + # mógł być widoczny jako dominujący sygnał). a = _meta( nazwisko="kowalski", imiona=("jan",), @@ -45,12 +46,16 @@ def test_rozne_orcid_odejmuje_50(): ) b = _meta( nazwisko="nowak", - imiona=("piotr",), + imiona=("jan",), orcid="0000-0002-2222-2222", ) score, reasons = analiza_pary_meta(a, b) - assert score <= -40 # -50 plus drobne plusy z innych kryteriów + # +30 wspólne imię, -50 różny ORCID, +10 mało publikacji = -10 raw, + # ale plus inne drobne. Sprawdzamy że ORCID-mismatch wypłynął jako + # negatywny element (nie samo +30 dominuje). assert any("różny ORCID" in r for r in reasons) + # Sumarycznie score powinien być wyraźnie obniżony przez ORCID + assert score < 30 def test_identyczne_nazwisko_dodaje_40(): @@ -81,3 +86,156 @@ def test_swap_imienia_z_nazwiskiem_dodaje_50(): b = _meta(nazwisko="jan", imiona=("kowalski",)) score, reasons = analiza_pary_meta(a, b) assert any("zamian" in r.lower() for r in reasons) + + +# --- Penalty za różne imiona (rozłączne, brak overlap-u w żadnym wymiarze) ---- + + +def test_rozne_imiona_bez_zadnego_overlap_odejmuje_punkty(): + """'Jan' vs 'Stefan': brak common, brak similar (3-prefix), brak inicjału. + + Realny case użytkownika: 'Jan Kowalski' vs 'Stefan + Kowalski-Nowak' — system dawał ~49% mimo zupełnie innych imion. + Penalty ma zniwelować inne przesłanki tak, żeby raw score spadał poniżej + progu MIN_CONFIDENCE_TO_STORE. + """ + a = _meta(nazwisko="kowalski", imiona=("jan",)) + b = _meta(nazwisko="kowalski-nowak", imiona=("stefan",)) + score, reasons = analiza_pary_meta(a, b) + assert any("różne imiona" in r.lower() for r in reasons), ( + f"Brak powodu 'różne imiona' w {reasons}" + ) + # Bez penalty: +30 (zawieranie nazwiska) + drobne plusy ≈ 30-50 raw. + # Z penalty -40: powinno spaść poniżej progu 50 wymaganego do zapisu. + assert score < 50, ( + f"Score {score} >= 50 mimo zupełnie różnych imion (powody: {reasons})" + ) + + +def test_jedno_wspolne_imie_nie_powoduje_penalty(): + """'Jan Maria' vs 'Maria Kasia' mają wspólne 'maria' — bez penalty.""" + a = _meta(imiona=("jan", "maria")) + b = _meta(imiona=("maria", "kasia")) + _, reasons = analiza_pary_meta(a, b) + assert not any("różne imiona" in r.lower() for r in reasons) + + +def test_podobne_imie_nie_powoduje_penalty(): + """'Jan' vs 'Janusz' — startsWith(3) wystarcza, brak penalty.""" + a = _meta(imiona=("jan",)) + b = _meta(imiona=("janusz",)) + _, reasons = analiza_pary_meta(a, b) + assert not any("różne imiona" in r.lower() for r in reasons) + + +def test_pasujacy_inicjal_nie_powoduje_penalty(): + """Wspólny inicjał (J vs J) traktowany jako sygnał - bez penalty.""" + # _common_initials w meta bierze pierwszy znak imienia. "jan" i "jakub" + # mają wspólny inicjał "j" — i jednocześnie startsWith(3) NIE pasuje + # ('jan' vs 'jakub' — różne 3 prefiksy 'jan' vs 'jak'). Penalty nie powinien + # pojawić się tylko z powodu wspólnego inicjału. + a = _meta(imiona=("jan",)) + b = _meta(imiona=("jakub",)) + _, reasons = analiza_pary_meta(a, b) + assert not any("różne imiona" in r.lower() for r in reasons) + + +def test_brak_imion_po_jednej_stronie_nie_aktywuje_penalty(): + """Hard-rejection wymaga, by OBIE strony miały imiona — w przeciwnym razie + nie ma o czym mówić, że są 'różne'.""" + a = _meta(imiona=("jan",)) + b = _meta(imiona=()) + _, reasons = analiza_pary_meta(a, b) + assert not any("różne imiona" in r.lower() for r in reasons) + + +# --- Hard rejection: rozłączne imiona = NIE jest duplikatem (regardless of all) ---- + + +def test_zupelnie_rozne_imiona_jest_hard_rejected(): + """Jan vs Agnieszka — różne imiona, brak swap → score mocno ujemny, + żeby pair na pewno NIE przeszedł progu zapisu.""" + a = _meta(nazwisko="kowalski", imiona=("jan",)) + b = _meta(nazwisko="kowalski", imiona=("agnieszka",)) + score, reasons = analiza_pary_meta(a, b) + assert score < 0, f"Score {score} - pair powinna być twardo odrzucona" + assert score <= -1000, f"Score {score} - powinien być sentinel ≤ -1000" + assert any("odrzucono" in r.lower() for r in reasons) + + +def test_hard_rejection_wygrywa_z_identycznym_orcid(): + """Nawet identyczny ORCID (+50) nie ratuje pary z totalnie różnymi imionami. + + Jeżeli ORCID jest taki sam ale imiona zupełnie różne, system nadal + odrzuca - to bardziej prawdopodobnie błąd w ORCID/imionach niż realny + duplikat (bo imiona człowieka raczej nie zmieniają się tak drastycznie). + """ + a = _meta(nazwisko="kowalski", imiona=("jan",), orcid="0000-0001-1111-1111") + b = _meta( + nazwisko="kowalski-nowak", imiona=("stefan",), orcid="0000-0001-1111-1111" + ) + score, reasons = analiza_pary_meta(a, b) + assert score <= -1000 + assert any("odrzucono" in r.lower() for r in reasons) + + +def test_hard_rejection_nie_blokuje_swap(): + """Klasyczny swap 'Jan Kowalski' ↔ 'Kowalski Jan' nie jest hard-rejected, + mimo że wartości imion się nie pokrywają z imionami drugiego.""" + a = _meta(nazwisko="kowalski", imiona=("jan",)) + b = _meta(nazwisko="jan", imiona=("kowalski",)) + score, reasons = analiza_pary_meta(a, b) + assert score > 0 + assert any("zamian" in r.lower() for r in reasons) + + +# --- Inicjały: kiedy MOŻE być duplikatem, kiedy NIE MOŻE ---------------------- + + +def test_jan_kowalski_vs_j_kropka_kowalski_moze_byc_duplikatem(): + """'Jan Kowalski' vs 'J. Kowalski' — to samo nazwisko, inicjał J się + zgadza → kandydat MOŻE być duplikatem (nie hard-reject).""" + a = _meta(nazwisko="kowalski", imiona=("jan",)) + b = _meta(nazwisko="kowalski", imiona=("j.",)) + score, reasons = analiza_pary_meta(a, b) + assert score > 0, ( + f"Para 'Jan' vs 'J.' powinna być akceptowalna, score={score}, reasons={reasons}" + ) + assert not any("odrzucono" in r.lower() for r in reasons) + + +def test_jan_kowalski_vs_a_kropka_kowalski_NIE_moze_byc_duplikatem(): + """'Jan Kowalski' vs 'A. Kowalski' — to samo nazwisko, ale inicjał A != J + → hard-reject (różne osoby).""" + a = _meta(nazwisko="kowalski", imiona=("jan",)) + b = _meta(nazwisko="kowalski", imiona=("a.",)) + score, reasons = analiza_pary_meta(a, b) + assert score <= -1000, ( + f"Inicjał A != J — para powinna być twardo odrzucona, score={score}" + ) + assert any("odrzucono" in r.lower() for r in reasons) + + +def test_jan_kowalski_vs_kowalski_j_swap_z_inicjalem_moze_byc(): + """'Jan Kowalski' vs 'Kowalski J.' — swap z inicjałem (database + swap: imiona='Kowalski', nazwisko='J.'). Inicjał J pasuje do 'Jan' → + MOŻE być duplikatem.""" + a = _meta(nazwisko="kowalski", imiona=("jan",)) + b = _meta(nazwisko="j.", imiona=("kowalski",)) + score, reasons = analiza_pary_meta(a, b) + assert score > 0, ( + f"Swap z pasującym inicjałem powinien przejść, score={score}, reasons={reasons}" + ) + assert not any("odrzucono" in r.lower() for r in reasons) + + +def test_jan_kowalski_vs_kowalski_a_swap_z_innym_inicjalem_NIE_moze_byc(): + """'Jan Kowalski' vs 'Kowalski A.' — swap-like layout, ale 'A.' nie + pasuje do 'Jan' (różne inicjały) → hard-reject.""" + a = _meta(nazwisko="kowalski", imiona=("jan",)) + b = _meta(nazwisko="a.", imiona=("kowalski",)) + score, reasons = analiza_pary_meta(a, b) + assert score <= -1000, ( + f"Swap-shape z różnym inicjałem powinien być odrzucony, score={score}" + ) + assert any("odrzucono" in r.lower() for r in reasons) diff --git a/src/deduplikator_autorow/tests/test_merge_all_refresh.py b/src/deduplikator_autorow/tests/test_merge_all_refresh.py new file mode 100644 index 000000000..d10f9ff86 --- /dev/null +++ b/src/deduplikator_autorow/tests/test_merge_all_refresh.py @@ -0,0 +1,194 @@ +"""Testy stanu "Scal wszystkie" — gating po pewności + dane potrzebne JS-owi +do odświeżenia stanu po AJAX-owym usunięciu karty (refreshMergeAllAvailability). + +Testy są server-side: sprawdzają dane, które view eksportuje do template-a, +a template do DOM-u. Zapewniają, że klient ma wszystko czego potrzebuje, żeby +prawidłowo przeliczyć stan przycisków bez przeładowania strony. + +Testowy E2E (kliknięcie "Nie jest duplikatem" + sprawdzenie odblokowania) +żyje w `test_merge_all_refresh_e2e.py` (Playwright). +""" + +import pytest +from django.contrib.auth.models import Group +from django.urls import reverse +from django.utils import timezone +from model_bakery import baker + +from bpp.const import GR_WPROWADZANIE_DANYCH +from deduplikator_autorow.models import DuplicateCandidate, DuplicateScanRun +from deduplikator_autorow.views import MIN_PEWNOSC_DO_WYSWIETLENIA + + +@pytest.fixture +def auth_client(client, db): + user = baker.make("bpp.BppUser", is_active=True) + user.set_password("xx") + user.save() + grp, _ = Group.objects.get_or_create(name=GR_WPROWADZANIE_DANYCH) + user.groups.add(grp) + client.force_login(user) + return client + + +def _create_candidate(scan, main, dup, confidence_percent, mode="pbn"): + """confidence_percent in 0..1 - musi przejść przez display=round(*100).""" + return DuplicateCandidate.objects.create( + scan_run=scan, + main_autor=main, + duplicate_autor=dup, + confidence_score=int(confidence_percent * 100), # nieistotne dla display + confidence_percent=confidence_percent, + main_autor_name=str(main), + duplicate_autor_name=str(dup), + scan_mode=mode, + ) + + +@pytest.fixture +def scan_with_mixed_confidence(db): + """Scan: 1 main author + 2 duplikaty (jeden 80%, jeden 30%).""" + scan = DuplicateScanRun.objects.create( + status=DuplicateScanRun.Status.COMPLETED, + finished_at=timezone.now(), + ) + main = baker.make("bpp.Autor", nazwisko="Kowalski", imiona="Jan") + high = baker.make("bpp.Autor", nazwisko="Kowalski", imiona="Jan") + low = baker.make("bpp.Autor", nazwisko="Kowal", imiona="Janusz") + high_cand = _create_candidate(scan, main, high, 0.80) + low_cand = _create_candidate(scan, main, low, 0.30) + return scan, main, high, low, high_cand, low_cand + + +@pytest.fixture +def scan_only_high_confidence(db): + """Scan: 1 main + 2 duplikaty - oba ≥ 50%.""" + scan = DuplicateScanRun.objects.create( + status=DuplicateScanRun.Status.COMPLETED, + finished_at=timezone.now(), + ) + main = baker.make("bpp.Autor", nazwisko="Nowak", imiona="Anna") + a = baker.make("bpp.Autor", nazwisko="Nowak", imiona="Anna") + b = baker.make("bpp.Autor", nazwisko="Nowak", imiona="Ania") + _create_candidate(scan, main, a, 0.85) + _create_candidate(scan, main, b, 0.65) + return scan, main + + +def test_view_exposes_pewnosc_threshold_to_template( + auth_client, scan_only_high_confidence +): + """JS musi znać próg, żeby przeliczyć po fadeOut. Testujemy że jest w DOM-ie.""" + response = auth_client.get(reverse("deduplikator_autorow:duplicate_authors")) + assert response.status_code == 200 + content = response.content.decode() + # Wartość MIN_PEWNOSC_DO_WYSWIETLENIA (50) powinna być wstawiona do JS-a + # jako var MIN_PEWNOSC_THRESHOLD = 50; + assert f"MIN_PEWNOSC_THRESHOLD = {MIN_PEWNOSC_DO_WYSWIETLENIA}" in content + + +def test_card_has_data_pewnosc_attribute(auth_client, scan_with_mixed_confidence): + """Każda karta musi mieć data-pewnosc - JS po fadeOut iteruje po nich.""" + response = auth_client.get(reverse("deduplikator_autorow:duplicate_authors")) + content = response.content.decode() + # Wysokopewny: 0.80 * 100 = 80 + assert 'data-pewnosc="80"' in content + # Niskopewny: 0.30 * 100 = 30 + assert 'data-pewnosc="30"' in content + + +def test_card_has_data_author_name_attribute(auth_client, scan_with_mixed_confidence): + """Każda karta ma data-author-name dla aktualizowanej listy w alercie.""" + response = auth_client.get(reverse("deduplikator_autorow:duplicate_authors")) + content = response.content.decode() + # Sprawdzamy że atrybut jest, z dowolnym sensownym tekstem reprezentującym autora + assert "data-author-name=" in content + + +def test_merge_all_disabled_when_low_confidence_present( + auth_client, scan_with_mixed_confidence +): + """Z 30% kandydatem przyciski 'Scal wszystkie' są wyszarzone.""" + response = auth_client.get(reverse("deduplikator_autorow:duplicate_authors")) + content = response.content.decode() + assert 'aria-disabled="true"' in content + assert "deduplikator-autorow__merge-all-btn--disabled" in content + + +def test_low_confidence_names_in_data_attribute( + auth_client, scan_with_mixed_confidence +): + """data-low-confidence-names przekazuje listę nazwisk do alertu JS.""" + response = auth_client.get(reverse("deduplikator_autorow:duplicate_authors")) + content = response.content.decode() + assert "data-low-confidence-names=" in content + # Zawiera autora 30% (Kowal Janusz) + assert "30%" in content + + +def test_merge_all_enabled_when_all_high_confidence( + auth_client, scan_only_high_confidence +): + """Wszyscy kandydaci ≥ 50% - przyciski aktywne, brak klasy disabled.""" + response = auth_client.get(reverse("deduplikator_autorow:duplicate_authors")) + content = response.content.decode() + # Przyciski merge-all renderują się + assert 'data-merge-all="true"' in content + # Atrybut HTML aria-disabled="true" w button-tagach NIE występuje obok + # przycisków merge-all. Klasa __merge-all-btn--disabled występuje też jako + # string w JS-ie (dla manipulacji classList), więc asercję robimy + # znajdując każdy
  • @@ -178,7 +177,7 @@
  • wysylka oswiadczen do PBN
  • kolejka eksportu do PBN
  • importer autorów PBN
  • -
  • deduplikator autorów PBN
  • +
  • deduplikator autorów
  • -
  • + + -