From 474ce4d2c06565d652470d0f2302279a4058b14f Mon Sep 17 00:00:00 2001 From: erjemin Date: Sun, 6 Sep 2026 20:53:27 +0300 Subject: [PATCH] =?UTF-8?q?add:=20Custom=20Command=20(4)=20=D0=98=D1=81?= =?UTF-8?q?=D0=BF=D1=80=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=20=D0=B8=20=D0=B8?= =?UTF-8?q?=D1=81=D0=BF=D1=8B=D1=82=D0=B0=D0=BD=20=D0=BF=D0=B5=D1=80=D0=B5?= =?UTF-8?q?=D1=80=D0=B0=D1=81=D1=87=D0=B5=D1=82=20=D1=80=D0=B5=D0=B9=D1=82?= =?UTF-8?q?=D0=B8=D0=BD=D0=B3=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- _blueprint/management-commands.md | 6 +- .../hypn0_site/management/commands/rescore.py | 77 +++++++++++-------- hypn0/hypn0_site/tests.py | 46 +++++++++++ 3 files changed, 93 insertions(+), 36 deletions(-) diff --git a/_blueprint/management-commands.md b/_blueprint/management-commands.md index 1ddef18..cd19e16 100644 --- a/_blueprint/management-commands.md +++ b/_blueprint/management-commands.md @@ -43,8 +43,10 @@ poetry run python hypn0/manage.py rescore --dry-run 4. **Гравитационная модель скоринга (с логарифмом):** $$\text{Score} = \frac{\ln(1 + \max(0, \text{WeightedLikes})) + \text{Bonus}_{\text{level}}}{(\text{Age in hours} + 2.0)^\gamma}$$ - - `LEVEL_1_BONUS` = $+1.5$ - - `LEVEL_2_BONUS` = $+4.0$ (Одобрено Мозговым Слизнем) + - `CANDIDATE_BONUS` = $+1.0$ (Свежая генерация) + - `LEVEL_1_BONUS` = $+1.5$ (Плеск бессознательного / прогрета) + - `LEVEL_2_BONUS` = $+3.0$ (Одобрено Мозговым Слизнем) + - `IMMORTAL_BONUS` = $+5.0$ (Глубокий транс / Золотой фонд) 5. **Инерционное сглаживание (EMA — Exponential Moving Average):** - Чтобы исключить резкие скачки рейтинга от разовых всплесков, итоговый `f_score` сглаживается с предыдущим значением: diff --git a/hypn0/hypn0_site/management/commands/rescore.py b/hypn0/hypn0_site/management/commands/rescore.py index 3fe4050..9081595 100644 --- a/hypn0/hypn0_site/management/commands/rescore.py +++ b/hypn0/hypn0_site/management/commands/rescore.py @@ -65,7 +65,7 @@ class Command(BaseCommand): "--shame-claim-ratio", type=float, default=0.35, - help="Доля жалоб среди свежих голосов для детекции шейминга (по умолчанию: 0.35 или 35%)", + help="Доля жалоб среди свежих голосов для детекции шейминга (по умолчанию: 0.35 или 35%%)", ) parser.add_argument( "--warmup-votes-threshold", @@ -92,9 +92,11 @@ class Command(BaseCommand): MIN_LIKES_FOR_SPIKE = 20 # Минимальное число лайков за окно, необходимое для признания относительного спайка COLD_START_SPIKE_LIKES = 50 # Абсолютное число лайков за окно для детекции спайка в режиме "холодного старта" - # 3. Бонусы к рейтингу за модераторский статус (веса числителя формулы) + # 3. Бонусы к рейтингу за статус (веса числителя формулы) + CANDIDATE_BONUS = 1.0 # Базовый бонус картине со статусом CANDIDATE (свежая генерация) LEVEL_1_BONUS = 1.5 # Бонус картине со статусом LEVEL_1 (прогрета / замечена модератором) - LEVEL_2_BONUS = 4.0 # Бонус картине со статусом LEVEL_2 (одобрено Мозговым Слизнем / шедевр) + LEVEL_2_BONUS = 3.0 # Бонус картине со статусом LEVEL_2 (одобрено Мозговым Слизнем / шедевр) + IMMORTAL_BONUS = 5.0 # Бонус картине со статусом IMMORTAL (золотой фонд / глубокий транс) # 4. Коэффициент сглаживания инерции (EMA — Exponential Moving Average) EMA_ALPHA = 0.7 # Доля нового значения в итоговом рейтинге (0.7 = 70% новый расчет + 30% старый f_score) @@ -145,9 +147,9 @@ class Command(BaseCommand): # ----------------------------------------------------------------------------------------- # ШАГ 2: Выборка картин для пересчета - # Бессмертные картины (Level.IMMORTAL) не требуют скоринга для очистки, но могут участвовать + # Включает все картины платформы, включая бессмертные (Level.IMMORTAL) # ----------------------------------------------------------------------------------------- - items = TbHypn0Item.objects.exclude(i_level=TbHypn0Item.Level.IMMORTAL).prefetch_related("votes") + items = TbHypn0Item.objects.all().prefetch_related("votes") total_items = items.count() if total_items == 0: @@ -177,50 +179,57 @@ class Command(BaseCommand): recent_claims_24h += 1 # 2. Детекция аномалий (шейминг vs спайк накрутки) + # Для картин со статусом IMMORTAL уровень зафиксирован и защищен от автосмены new_level = item.i_level recent_total_24h = recent_likes_24h + recent_claims_24h - # Проверка аномалий активируется только при наличии минимальной выборки (>= MIN_VOTES_FOR_ANOMALY) - if recent_total_24h >= MIN_VOTES_FOR_ANOMALY: - # Проверка на шейминг (резкий наплыв жалоб) - claim_ratio = recent_claims_24h / recent_total_24h - if claim_ratio >= shame_claim_ratio: - new_level = TbHypn0Item.Level.SHAMED - self.stdout.write( - self.style.WARNING(f"Аномалия [SHAMED]: Картина {item.s_hash_id} доля жалоб={claim_ratio:.1%}") - ) + if item.i_level != TbHypn0Item.Level.IMMORTAL: + # Проверка аномалий активируется только при наличии минимальной выборки (>= MIN_VOTES_FOR_ANOMALY) + if recent_total_24h >= MIN_VOTES_FOR_ANOMALY: + # Проверка на шейминг (резкий наплыв жалоб) + claim_ratio = recent_claims_24h / recent_total_24h + if claim_ratio >= shame_claim_ratio: + new_level = TbHypn0Item.Level.SHAMED + self.stdout.write( + self.style.WARNING(f"Аномалия [SHAMED]: Картина {item.s_hash_id} доля жалоб={claim_ratio:.1%}") + ) - # Проверка на спайк лайков (накрутка): - # На холодном старте (когда avg_votes_per_item_24h близко к 0) опираемся только на высокий абсолютный порог COLD_START_SPIKE_LIKES, - # чтобы 3-4 первых лайка от живых пользователей не помечались как подозрительные. - elif not is_cold_start and avg_votes_per_item_24h > 0: - if recent_likes_24h > (avg_votes_per_item_24h * spike_multiplier) and recent_likes_24h >= MIN_LIKES_FOR_SPIKE: + # Проверка на спайк лайков (накрутка): + # На холодном старте (когда avg_votes_per_item_24h близко к 0) опираемся только на высокий абсолютный порог COLD_START_SPIKE_LIKES, + # чтобы 3-4 первых лайка от живых пользователей не помечались как подозрительные. + elif not is_cold_start and avg_votes_per_item_24h > 0: + if recent_likes_24h > (avg_votes_per_item_24h * spike_multiplier) and recent_likes_24h >= MIN_LIKES_FOR_SPIKE: + new_level = TbHypn0Item.Level.SUSPICIOUS + self.stdout.write( + self.style.WARNING(f"Аномалия [SUSPICIOUS]: Картина {item.s_hash_id} лайков за окно={recent_likes_24h}") + ) + elif is_cold_start and recent_likes_24h >= COLD_START_SPIKE_LIKES: new_level = TbHypn0Item.Level.SUSPICIOUS self.stdout.write( - self.style.WARNING(f"Аномалия [SUSPICIOUS]: Картина {item.s_hash_id} лайков за окно={recent_likes_24h}") + self.style.WARNING(f"Аномалия [SUSPICIOUS (Cold Start)]: Картина {item.s_hash_id} лайков за окно={recent_likes_24h}") ) - elif is_cold_start and recent_likes_24h >= COLD_START_SPIKE_LIKES: - new_level = TbHypn0Item.Level.SUSPICIOUS - self.stdout.write( - self.style.WARNING(f"Аномалия [SUSPICIOUS (Cold Start)]: Картина {item.s_hash_id} лайков за окно={recent_likes_24h}") - ) - # Проверка перехода CANDIDATE -> LEVEL_1 (Прогрета, набрала массу) - if item.i_level == TbHypn0Item.Level.CANDIDATE and weighted_likes >= warmup_threshold and new_level == item.i_level: - new_level = TbHypn0Item.Level.LEVEL_1 - self.stdout.write( - self.style.SUCCESS(f"Прогрев [CANDIDATE -> LEVEL_1]: Картина {item.s_hash_id} набрала вес={weighted_likes:.1f}") - ) + # Проверка перехода CANDIDATE -> LEVEL_1 (Прогрета, набрала массу) + if item.i_level == TbHypn0Item.Level.CANDIDATE and weighted_likes >= warmup_threshold and new_level == item.i_level: + new_level = TbHypn0Item.Level.LEVEL_1 + self.stdout.write( + self.style.SUCCESS(f"Прогрев [CANDIDATE -> LEVEL_1]: Картина {item.s_hash_id} набрала вес={weighted_likes:.1f}") + ) # 3. Расчет нового f_score (Гравитационная модель + логарифм) item_age_hours = max(0.0, (now - item.d_created_at).total_seconds() / 3600.0) - # Бонус от модераторского уровня - level_bonus = 0.0 - if item.i_level == TbHypn0Item.Level.LEVEL_1: + # Бонус от уровня картины + if item.i_level == TbHypn0Item.Level.CANDIDATE: + level_bonus = CANDIDATE_BONUS + elif item.i_level == TbHypn0Item.Level.LEVEL_1: level_bonus = LEVEL_1_BONUS elif item.i_level == TbHypn0Item.Level.LEVEL_2: level_bonus = LEVEL_2_BONUS + elif item.i_level == TbHypn0Item.Level.IMMORTAL: + level_bonus = IMMORTAL_BONUS + else: + level_bonus = 0.0 if weighted_likes <= 0: raw_score = weighted_likes # Отрицательный или нулевой скор diff --git a/hypn0/hypn0_site/tests.py b/hypn0/hypn0_site/tests.py index a757220..94e9cca 100644 --- a/hypn0/hypn0_site/tests.py +++ b/hypn0/hypn0_site/tests.py @@ -3,6 +3,7 @@ import shutil import tempfile from django.core.files.base import ContentFile from django.core.files.uploadedfile import SimpleUploadedFile +from django.core.management import call_command from django.test import Client, TestCase, override_settings from django.urls import reverse from PIL import Image @@ -1012,3 +1013,48 @@ class GalleryArchiveTests(BaseMediaTestCase): self.assertEqual(len(resp_p2.context["page_obj"]), 2) self.assertContains(resp_p2, "Фаза 2 из 2") self.assertContains(resp_p2, "floor=all&sort=likes&page=1") + + +class RescoreCommandTests(BaseMediaTestCase): + """Тестирование management-команды rescore.""" + + def setUp(self): + self.vid = "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4" + svg_bytes = b'' + self.item_immortal = TbHypn0Item( + s_title="Бессмертная картина", + file_svg=ContentFile(svg_bytes, name="immortal.svg"), + i_level=TbHypn0Item.Level.IMMORTAL, + f_score=0.0, + is_public=True, + ) + self.item_immortal.save(visitor_uuid_or_fp=self.vid) + + self.item_candidate = TbHypn0Item( + s_title="Свежая картина", + file_svg=ContentFile(svg_bytes, name="cand.svg"), + i_level=TbHypn0Item.Level.CANDIDATE, + f_score=0.0, + is_public=True, + ) + self.item_candidate.save(visitor_uuid_or_fp=self.vid) + + def test_rescore_includes_immortal_and_applies_bonus(self): + # Добавляем голос + TbVote.objects.create( + k_item=self.item_immortal, + s_fingerprint="fp1", + i_direction=TbVote.Direction.LIKE, + ) + out = io.StringIO() + call_command("rescore", stdout=out) + self.item_immortal.refresh_from_db() + self.item_candidate.refresh_from_db() + + # Проверяем, что f_score рассчитан (> 0) и для бессмертной, и для кандидата + self.assertGreater(self.item_immortal.f_score, 0.0) + self.assertGreater(self.item_candidate.f_score, 0.0) + # У бессмертной картины бонус выше (5.0 против 1.0) + self.assertGreater(self.item_immortal.f_score, self.item_candidate.f_score) + # Статус бессмертной картины защищен и не должен измениться + self.assertEqual(self.item_immortal.i_level, TbHypn0Item.Level.IMMORTAL)