Files
OnlineJudge/achievement/metrics.py

356 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""成就指标注册表。
这里定义"能测量什么",后台定义"多少算达成"。管理员在后台看到的指标下拉框
就是 METRIC_REGISTRY 的 key 列表。加一个新维度必须改本文件并部署,
之后在该维度上加任意多条成就都是纯配置。
约定指标从未产生过有效值时recompute 返回 None调用方删除该 key。
metrics 字典里 key 不存在 == 未达标,判定时直接跳过。
"""
import logging
from django.db.models import Count, Q
from django.utils import timezone
from submission.models import JudgeStatus, Submission, is_accepted
logger = logging.getLogger(__name__)
METRIC_REGISTRY = {}
META_METRICS = set()
# AC 口径与全项目一致AST_CHECK_FAILED代码结构检查未通过但测试点全过也算通过。
# 见 submission/models.py 的 is_accepted()。ORM 过滤用这个常量,标量比较用 is_accepted()。
ACCEPTED_RESULTS = (JudgeStatus.ACCEPTED, JudgeStatus.AST_CHECK_FAILED)
class BaseMetric:
key = ""
name = ""
help_text = ""
def on_submission(self, metrics, sub, ctx):
"""判题完成后增量更新 metrics原地修改"""
raise NotImplementedError
def recompute(self, user):
"""全量重算。返回 None 表示该用户此指标无有效值。"""
raise NotImplementedError
def recompute_state(self, user):
"""重算时一并重建增量所需的辅助键,返回 {key: value}。
默认无辅助状态。凡是 on_submission 依赖 `_` 前缀辅助键的指标都必须重写
本方法:否则全量重算把辅助键丢掉后,下一次判题会从零重新累积,指标当场
回退active_days 从 45 掉回 1且要等到下一次重算才恢复。
"""
return {}
def metric(key, name, help_text="", meta=False):
def deco(cls):
cls.key = key
cls.name = name
cls.help_text = help_text
METRIC_REGISTRY[key] = cls()
if meta:
META_METRICS.add(key)
return cls
return deco
def _practice_submissions(user_id):
"""成就只统计平时练习的提交,比赛提交不计入。"""
return Submission.objects.filter(user_id=user_id, contest_id__isnull=True)
def build_ctx(user_id, sub):
"""判题后预查一次,所有指标复用,避免每个指标各自查库。
比赛提交不参与成就统计,此时返回 skip=True调用方直接跳过整轮判定。
"""
if sub.contest_id is not None:
return {"skip": True}
prior = _practice_submissions(user_id).filter(problem_id=sub.problem_id).exclude(id=sub.id)
prior_stats = prior.aggregate(
total=Count("id"),
accepted=Count("id", filter=Q(result__in=ACCEPTED_RESULTS)),
)
local_now = timezone.localtime(sub.create_time)
sub_is_accepted = is_accepted(sub.result)
return {
"skip": False,
"is_accepted": sub_is_accepted,
# 该题此前的提交次数与 AC 次数
"prior_count": prior_stats["total"],
"prior_accepted": prior_stats["accepted"],
# 首次 AC 这道题(此前从未 AC 过)
"is_first_ac_of_problem": sub_is_accepted and prior_stats["accepted"] == 0,
# 一发入魂:此前无任何提交且本次 AC
"is_first_try_ac": sub_is_accepted and prior_stats["total"] == 0,
"local_date": local_now.date().isoformat(),
"local_hour": local_now.hour,
}
@metric("accepted_count", "AC 题目数", "去重后通过的题目数量(不含比赛)")
class AcceptedCount(BaseMetric):
def on_submission(self, metrics, sub, ctx):
if ctx["is_first_ac_of_problem"]:
metrics["accepted_count"] = metrics.get("accepted_count", 0) + 1
def recompute(self, user):
return _practice_submissions(user.id).filter(result__in=ACCEPTED_RESULTS).values("problem_id").distinct().count()
@metric("submission_count", "提交总数", "提交次数(不含比赛)")
class SubmissionCount(BaseMetric):
def on_submission(self, metrics, sub, ctx):
metrics["submission_count"] = metrics.get("submission_count", 0) + 1
def recompute(self, user):
return _practice_submissions(user.id).count()
@metric("active_days", "活跃天数", "有过提交的累计天数")
class ActiveDays(BaseMetric):
def on_submission(self, metrics, sub, ctx):
seen = metrics.get("_active_dates", [])
if ctx["local_date"] not in seen:
seen.append(ctx["local_date"])
metrics["_active_dates"] = seen
metrics["active_days"] = len(seen)
def recompute(self, user):
dates = {timezone.localtime(t).date().isoformat() for t in _practice_submissions(user.id).values_list("create_time", flat=True)}
return len(dates)
def recompute_state(self, user):
dates = sorted({timezone.localtime(t).date().isoformat() for t in _practice_submissions(user.id).values_list("create_time", flat=True)})
return {"_active_dates": dates}
@metric("max_ac_streak_days", "最长连续 AC 天数", "连续每天至少 AC 一题的最长天数")
class MaxAcStreakDays(BaseMetric):
def on_submission(self, metrics, sub, ctx):
if not ctx["is_accepted"]:
return
today = ctx["local_date"]
last = metrics.get("_last_ac_date")
if last == today:
return
current = metrics.get("_current_ac_streak", 0)
if last and (timezone.datetime.fromisoformat(today) - timezone.datetime.fromisoformat(last)).days == 1:
current += 1
else:
current = 1
metrics["_last_ac_date"] = today
metrics["_current_ac_streak"] = current
metrics["max_ac_streak_days"] = max(metrics.get("max_ac_streak_days", 0), current)
def recompute(self, user):
dates = sorted({timezone.localtime(t).date() for t in _practice_submissions(user.id).filter(result__in=ACCEPTED_RESULTS).values_list("create_time", flat=True)})
if not dates:
return None
best = current = 1
for prev, cur in zip(dates, dates[1:]):
current = current + 1 if (cur - prev).days == 1 else 1
best = max(best, current)
return best
def recompute_state(self, user):
dates = sorted({timezone.localtime(t).date() for t in _practice_submissions(user.id).filter(result__in=ACCEPTED_RESULTS).values_list("create_time", flat=True)})
if not dates:
return {}
current = 1
for prev, cur in zip(dates, dates[1:]):
current = current + 1 if (cur - prev).days == 1 else 1
return {"_last_ac_date": dates[-1].isoformat(), "_current_ac_streak": current}
@metric("languages_used", "使用语言数", "用过多少种编程语言")
class LanguagesUsed(BaseMetric):
def on_submission(self, metrics, sub, ctx):
seen = metrics.get("_languages", [])
if sub.language not in seen:
seen.append(sub.language)
metrics["_languages"] = seen
metrics["languages_used"] = len(seen)
def recompute(self, user):
return _practice_submissions(user.id).values("language").distinct().count()
def recompute_state(self, user):
return {"_languages": list(_practice_submissions(user.id).values_list("language", flat=True).distinct())}
@metric("contest_joined", "参赛场次", "参加过的比赛数量(本指标是比赛维度,不受比赛提交不计入的限制)")
class ContestJoined(BaseMetric):
def on_submission(self, metrics, sub, ctx):
# 比赛提交在 build_ctx 就被跳过,本指标只走 recompute
return
def recompute(self, user):
return Submission.objects.filter(user_id=user.id, contest_id__isnull=False).values("contest_id").distinct().count()
@metric("badge_count", "题单奖章数", "获得的题单奖章数量")
class BadgeCount(BaseMetric):
def on_submission(self, metrics, sub, ctx):
# 奖章由题单流程颁发,本指标只走 recompute见 Task 7
return
def recompute(self, user):
from problemset.models import UserBadge
return UserBadge.objects.filter(user=user).count()
@metric("problemset_completed", "完成题单数", "完成的题单数量")
class ProblemSetCompleted(BaseMetric):
def on_submission(self, metrics, sub, ctx):
return
def recompute(self, user):
from problemset.models import ProblemSetProgress
return ProblemSetProgress.objects.filter(user=user, complete_time__isnull=False).count()
@metric("first_try_ac_count", "一发入魂次数", "首次提交即通过的次数")
class FirstTryAcCount(BaseMetric):
def on_submission(self, metrics, sub, ctx):
if ctx["is_first_try_ac"]:
metrics["first_try_ac_count"] = metrics.get("first_try_ac_count", 0) + 1
def recompute(self, user):
count = 0
seen = set()
for s in _practice_submissions(user.id).order_by("create_time").values("problem_id", "result"):
if s["problem_id"] in seen:
continue
seen.add(s["problem_id"])
if is_accepted(s["result"]):
count += 1
return count
@metric("midnight_submissions", "凌晨提交次数", "0:005:00 之间的提交次数")
class MidnightSubmissions(BaseMetric):
def on_submission(self, metrics, sub, ctx):
if 0 <= ctx["local_hour"] < 5:
metrics["midnight_submissions"] = metrics.get("midnight_submissions", 0) + 1
def recompute(self, user):
return sum(1 for t in _practice_submissions(user.id).values_list("create_time", flat=True) if 0 <= timezone.localtime(t).hour < 5)
@metric("compile_error_count", "编译错误次数", "累计编译错误的次数")
class CompileErrorCount(BaseMetric):
def on_submission(self, metrics, sub, ctx):
if sub.result == JudgeStatus.COMPILE_ERROR:
metrics["compile_error_count"] = metrics.get("compile_error_count", 0) + 1
def recompute(self, user):
return _practice_submissions(user.id).filter(result=JudgeStatus.COMPILE_ERROR).count()
@metric("max_wa_before_ac", "屡败屡战", "单题失败最多多少次后终于通过")
class MaxWaBeforeAc(BaseMetric):
def on_submission(self, metrics, sub, ctx):
if ctx["is_first_ac_of_problem"]:
metrics["max_wa_before_ac"] = max(metrics.get("max_wa_before_ac", 0), ctx["prior_count"])
def recompute(self, user):
best = None
attempts = {}
for s in _practice_submissions(user.id).order_by("create_time").values("problem_id", "result"):
pid = s["problem_id"]
if pid in attempts and attempts[pid] is None:
continue
if is_accepted(s["result"]):
best = max(best or 0, attempts.get(pid, 0))
attempts[pid] = None
else:
attempts[pid] = attempts.get(pid, 0) + 1
return best
@metric("max_ac_in_one_day", "单日最多 AC", "一天之内最多通过多少题")
class MaxAcInOneDay(BaseMetric):
def on_submission(self, metrics, sub, ctx):
if not ctx["is_first_ac_of_problem"]:
return
counts = metrics.get("_ac_per_day", {})
counts[ctx["local_date"]] = counts.get(ctx["local_date"], 0) + 1
metrics["_ac_per_day"] = counts
metrics["max_ac_in_one_day"] = max(counts.values())
def recompute(self, user):
counts = {}
seen = set()
for s in _practice_submissions(user.id).filter(result__in=ACCEPTED_RESULTS).order_by("create_time").values("problem_id", "create_time"):
if s["problem_id"] in seen:
continue
seen.add(s["problem_id"])
day = timezone.localtime(s["create_time"]).date().isoformat()
counts[day] = counts.get(day, 0) + 1
return max(counts.values()) if counts else None
def recompute_state(self, user):
counts = {}
seen = set()
for s in _practice_submissions(user.id).filter(result__in=ACCEPTED_RESULTS).order_by("create_time").values("problem_id", "create_time"):
if s["problem_id"] in seen:
continue
seen.add(s["problem_id"])
day = timezone.localtime(s["create_time"]).date().isoformat()
counts[day] = counts.get(day, 0) + 1
return {"_ac_per_day": counts}
@metric("min_ac_code_chars", "最短 AC 代码", "通过的代码里最短的字符数(配小于等于使用)")
class MinAcCodeChars(BaseMetric):
def on_submission(self, metrics, sub, ctx):
if not ctx["is_accepted"]:
return
length = len(sub.code)
cur = metrics.get("min_ac_code_chars")
metrics["min_ac_code_chars"] = length if cur is None else min(cur, length)
def recompute(self, user):
lengths = [len(c) for c in _practice_submissions(user.id).filter(result__in=ACCEPTED_RESULTS).values_list("code", flat=True)]
return min(lengths) if lengths else None
@metric("max_code_lines", "最长代码行数", "提交过的最长代码有多少行")
class MaxCodeLines(BaseMetric):
def on_submission(self, metrics, sub, ctx):
lines = len(sub.code.splitlines())
metrics["max_code_lines"] = max(metrics.get("max_code_lines", 0), lines)
def recompute(self, user):
counts = [len(c.splitlines()) for c in _practice_submissions(user.id).values_list("code", flat=True)]
return max(counts) if counts else None
@metric("achievement_unlocked_count", "已解锁成就数", "已解锁的成就数量(不含白金档)", meta=True)
class AchievementUnlockedCount(BaseMetric):
"""自引用指标:解锁成就会改变它。
因此判定流程限定为最多两轮(见 checker.py且口径排除白金档自身
避免「集齐 N 个成就」这类白金奖杯把自己算进分子。
"""
def on_submission(self, metrics, sub, ctx):
# 由 checker 在第一轮解锁后显式重算,不参与增量更新
return
def recompute(self, user):
from achievement.models import Rarity, UserAchievement
return UserAchievement.objects.filter(user=user).exclude(achievement__rarity=Rarity.PLATINUM).count()