import math from datetime import timedelta from django.core.management.base import BaseCommand from django.utils import timezone from django.conf import settings from django.db.models import Count, Q from hypn0_site.models import TbHypn0Item, TbVote class Command(BaseCommand): """ Пакетный пересчет рейтинга популярности (f_score) и выявление аномалий (накрутка / шейминг). АРХИТЕКТУРНЫЕ ПРИНЦИПЫ СКОРИНГА HYPN0: ====================================== 1. Изолированный атомарный скоринг бесполезен: Оценивать одну картину в вакууме нельзя, так как система не видит контекста: динамики платформы, скорости притока голосов и фонового шума. 2. Рейтинг f_score нужен для Smart Retention (умной очистки диска): Он определяет, какие файлы удалять первыми при превышении дисковой квоты, а какие оставлять как ценные экспонаты. 3. Разделение UI-счетчиков и весов скоринга: Поля `i_likes_count` и `i_claims_count` в TbHypn0Item используются для быстрого рендера в UI. Истинные веса (+1 лайк, +2 автор, -2 жалоба) + штампы-времени и sha256 от фингер-принтами браузеров хранятся в TbVote. 4. Учет "старения" голосов (Time-Decay) (алгоритм, возможно, будет пересмотрен): Свежий лайк имеет полный вес, а старый голос плавно затухает по экспоненциальному закону: Weight(t) = BaseWeight * 2^(-dt / HalfLife). 5. "Средняя температура по больнице" и Коридоры аномалий (алгоритм, возможно, будет пересмотрен: Перед расчетом анализируется средняя скорость голосования по всей платформе за окно (например, 24ч). - Спайк лайков (> N раз выше нормы): подозрение на накрутку (Level.SUSPICIOUS). - Спайк клеймов (> N% от голосов за короткий промежуток): скоординированный шейминг (Level.SHAMED). - Стабильный интерес: прогретая картина переходит в Level.LEVEL_1 (ожидает модерации). 6. Базовая формула гравитации с логарифмическим сглаживанием: Score = (ln(1 + max(0, WeightedVotes)) + Bonus_level) / ((Age_in_hours + 2) ^ GAMMA) """ help = "Пакетный пересчет рейтинга популярности f_score и выявление аномалий в голосах" def add_arguments(self, parser): parser.add_argument( "--gamma", type=float, default=getattr(settings, "GAMMA", 1.5), help="Коэффициент гравитации затухания картины по времени (по умолчанию: 1.5)", ) parser.add_argument( "--half-life-days", type=float, default=14.0, help="Период полураспада веса отдельного голоса в днях (по умолчанию: 14 дней)", ) parser.add_argument( "--spike-multiplier", type=float, default=4.0, help="Во сколько раз скорость лайков должна превышать среднюю для детекции накрутки (по умолчанию: 4.0)", ) parser.add_argument( "--shame-claim-ratio", type=float, default=0.35, help="Доля жалоб среди свежих голосов для детекции шейминга (по умолчанию: 0.35 или 35%)", ) parser.add_argument( "--warmup-votes-threshold", type=int, default=10, help="Минимальное число взвешенных голосов для перехода CANDIDATE -> LEVEL_1 (по умолчанию: 10)", ) parser.add_argument( "--dry-run", action="store_true", help="Тестовый прогон: выполнить все расчеты без записи изменений в БД", ) def handle(self, *args, **options): # ========================================================================================= # КОНФИГУРАЦИОННЫЕ КОНСТАНТЫ И "МАГИЧЕСКИЕ ЧИСЛА" СКОРИНГА # ========================================================================================= # 1. Параметры времени и окна анализа WINDOW_HOURS = 24.0 # Размер скользящего окна (в часах) для оценки текущей активности ACTIVE_DAYS_HORIZON = 30.0 # Горизонт (в днях), картины моложе которого считаются "активными" для базы нормы # 2. Пороги выборки и детекции аномалий MIN_VOTES_FOR_ANOMALY = 5 # Минимальное число свежих оценок за окно, до которого детекторы аномалий спят MIN_LIKES_FOR_SPIKE = 20 # Минимальное число лайков за окно, необходимое для признания относительного спайка COLD_START_SPIKE_LIKES = 50 # Абсолютное число лайков за окно для детекции спайка в режиме "холодного старта" # 3. Бонусы к рейтингу за модераторский статус (веса числителя формулы) LEVEL_1_BONUS = 1.5 # Бонус картине со статусом LEVEL_1 (прогрета / замечена модератором) LEVEL_2_BONUS = 4.0 # Бонус картине со статусом LEVEL_2 (одобрено Мозговым Слизнем / шедевр) # 4. Коэффициент сглаживания инерции (EMA — Exponential Moving Average) EMA_ALPHA = 0.7 # Доля нового значения в итоговом рейтинге (0.7 = 70% новый расчет + 30% старый f_score) # 5. Смещение возраста в формуле гравитации (Hacker News Gravity Offset) GRAVITY_AGE_OFFSET_HOURS = 2.0 # Добавка к возрасту картины в часах, исключающая деление на 0 в первые минуты жизни # Параметры из аргументов CLI (переопределяемые) gamma = options["gamma"] half_life_days = options["half_life_days"] spike_multiplier = options["spike_multiplier"] shame_claim_ratio = options["shame_claim_ratio"] warmup_threshold = options["warmup_votes_threshold"] dry_run = options["dry_run"] now = timezone.now() self.stdout.write(self.style.NOTICE(f"[{now.strftime('%Y-%m-%d %H:%M:%S')}] Старт скоринга Hypn0...")) # ----------------------------------------------------------------------------------------- # ШАГ 1: Определение "средней температуры по больнице" за последние WINDOW_HOURS # ----------------------------------------------------------------------------------------- # ЗАЩИТА "ХОЛОДНОГО СТАРТА" (Cold Start / Нулевая база): # Если проект только запущен или база пуста, средние значения равны нулю, # а статистическая выборка отсутствует. В этом режиме детекторы спайков # используют безопасные абсолютные пороги, чтобы не давать ложных тревог. # ----------------------------------------------------------------------------------------- recent_window = now - timedelta(hours=WINDOW_HOURS) recent_votes_qs = TbVote.objects.filter(d_created_at__gte=recent_window) total_recent_votes = recent_votes_qs.count() active_items_count = TbHypn0Item.objects.filter( d_created_at__gte=now - timedelta(days=ACTIVE_DAYS_HORIZON) ).count() if active_items_count == 0 or total_recent_votes == 0: avg_votes_per_item_24h = 0.0 is_cold_start = True self.stdout.write( self.style.NOTICE("Режим [Холодный старт]: платформа только запустилась, голосов мало/нет.") ) else: avg_votes_per_item_24h = total_recent_votes / active_items_count is_cold_start = False self.stdout.write( f"Базовые метрики за {int(WINDOW_HOURS)}ч: голосов всего={total_recent_votes}, " f"активных картин={active_items_count}, среднее={avg_votes_per_item_24h:.2f} голосов/картину" ) # ----------------------------------------------------------------------------------------- # ШАГ 2: Выборка картин для пересчета # Бессмертные картины (Level.IMMORTAL) не требуют скоринга для очистки, но могут участвовать # ----------------------------------------------------------------------------------------- items = TbHypn0Item.objects.exclude(i_level=TbHypn0Item.Level.IMMORTAL).prefetch_related("votes") total_items = items.count() if total_items == 0: self.stdout.write(self.style.WARNING("В базе данных пока нет картин для пересчета.")) return updated_count = 0 decay_lambda = math.log(2) / (half_life_days * 24.0) # константа затухания в час for item in items: # 1. Считаем взвешенные голоса с учетом возраста каждого голоса item_votes = list(item.votes.all()) weighted_likes = 0.0 recent_likes_24h = 0 recent_claims_24h = 0 for v in item_votes: vote_age_hours = max(0.0, (now - v.d_created_at).total_seconds() / 3600.0) # Экспоненциальное затухание веса: e^(-lambda * dt) decay_factor = math.exp(-decay_lambda * vote_age_hours) weighted_likes += v.i_direction * decay_factor if v.d_created_at >= recent_window: if v.i_direction in (TbVote.Direction.LIKE, TbVote.Direction.AUTHOR): recent_likes_24h += 1 elif v.i_direction == TbVote.Direction.CLAIM: recent_claims_24h += 1 # 2. Детекция аномалий (шейминг vs спайк накрутки) 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%}") ) # Проверка на спайк лайков (накрутка): # На холодном старте (когда 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 (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}") ) # 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: level_bonus = LEVEL_1_BONUS elif item.i_level == TbHypn0Item.Level.LEVEL_2: level_bonus = LEVEL_2_BONUS if weighted_likes <= 0: raw_score = weighted_likes # Отрицательный или нулевой скор else: raw_score = (math.log(1.0 + weighted_likes) + level_bonus) / ((item_age_hours + GRAVITY_AGE_OFFSET_HOURS) ** gamma) # 4. Инерционное сглаживание со старым значением (EMA) для исключения скачков if item.f_score != 0.0: final_score = EMA_ALPHA * raw_score + (1.0 - EMA_ALPHA) * item.f_score else: final_score = raw_score # 5. Сохранение результатов if not dry_run: item.f_score = round(final_score, 6) if new_level != item.i_level: item.i_level = new_level item.save(update_fields=["f_score", "i_level", "d_updated_at"]) else: item.save(update_fields=["f_score", "d_updated_at"]) updated_count += 1 status_msg = "ПРОБНЫЙ ПРОГОН (DRY RUN)" if dry_run else "УСПЕШНО ОБНОВЛЕНО" self.stdout.write( self.style.SUCCESS(f"[{status_msg}] Обработано картин: {updated_count}/{total_items}") )