Files
OnlineJudge/achievement/metrics.py
yuetsh e9c7793714 feat(achievement): 添加按难度的 AC 题数指标,删除 min_ac_code_chars
新增 mid_ac_count / hard_ac_count,去重统计中等和困难难度的 AC 题数。
在此之前所有成就对水题和难题一视同仁,这是最缺的一个维度。
增量走 build_ctx 新增的 problem_difficulty,它只在首次 AC 时才查库——
绝大多数提交都不是首次 AC,无条件预查等于给每次判题白加一条 SQL。

删除 min_ac_code_chars:线上实测 1314 个用户的分布,最小值 8、p5=10,
有道题 8 个字符就能通过,这个指标测的是"谁做过那道水题"而不是
"谁写得简洁",配不出有意义的成就。

自检里写死 min_ac_code_chars 的两处改成按 lte 成就自动发现:
_check_registry 不再要求某个具体指标存在,_check_min_metric_absent
改为遍历所有上架的 lte 成就检查其指标。没有 lte 成就时 SKIP,
将来配了自动开始检查。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 11:02:36 -06:00

404 lines
16 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
from utils.constants import Difficulty
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)
is_first_ac_of_problem = sub_is_accepted and prior_stats["accepted"] == 0
# 难度只有首次 AC 时才用得上,其余情况不查这一次库——
# 绝大多数提交都不是首次 AC放在外面等于给每次判题白加一条 SQL
difficulty = None
if is_first_ac_of_problem:
from problem.models import Problem
difficulty = Problem.objects.filter(id=sub.problem_id).values_list("difficulty", flat=True).first()
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": is_first_ac_of_problem,
# 一发入魂:此前无任何提交且本次 AC
"is_first_try_ac": sub_is_accepted and prior_stats["total"] == 0,
# 本题难度,仅首次 AC 时有值
"problem_difficulty": difficulty,
"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).order_by().values("problem_id").distinct().count()
class _DifficultyAcCount(BaseMetric):
"""按难度去重统计 AC 题数。子类只需指定 difficulty。
增量靠 ctx["problem_difficulty"],它只在首次 AC 时才有值——与
is_first_ac_of_problem 是同一个条件,所以两者一起判即可。
"""
difficulty = ""
def on_submission(self, metrics, sub, ctx):
if ctx["is_first_ac_of_problem"] and ctx["problem_difficulty"] == self.difficulty:
metrics[self.key] = metrics.get(self.key, 0) + 1
def recompute(self, user):
return _practice_submissions(user.id).filter(result__in=ACCEPTED_RESULTS, problem__difficulty=self.difficulty).order_by().values("problem_id").distinct().count()
@metric("mid_ac_count", "中等题 AC 数", "去重后通过的中等难度题目数(不含比赛)")
class MidAcCount(_DifficultyAcCount):
difficulty = Difficulty.MID
@metric("hard_ac_count", "困难题 AC 数", "去重后通过的困难题目数(不含比赛)")
class HardAcCount(_DifficultyAcCount):
difficulty = Difficulty.HIGH
@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).order_by().values("language").distinct().count()
def recompute_state(self, user):
# order_by() 不能省Submission.Meta 有默认排序 ("-create_time",)
# Django 会把排序字段并入 DISTINCT于是每条提交各成一行——
# 实测某用户返回 659 条而不是 5 种语言。
# recompute 侥幸正确只是因为 .count() 会清掉排序,不能依赖这一点。
return {"_languages": list(_practice_submissions(user.id).order_by().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).order_by().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("early_bird_submissions", "早起提交次数", "5:007:00 之间的提交次数")
class EarlyBirdSubmissions(BaseMetric):
"""与 midnight_submissions 对称的作息维度。5 点是两者的分界,不重叠。"""
def on_submission(self, metrics, sub, ctx):
if 5 <= ctx["local_hour"] < 7:
metrics["early_bird_submissions"] = metrics.get("early_bird_submissions", 0) + 1
def recompute(self, user):
return sum(1 for t in _practice_submissions(user.id).values_list("create_time", flat=True) if 5 <= timezone.localtime(t).hour < 7)
@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}
# 曾经这里有 min_ac_code_chars最短 AC 代码)。线上实测 1314 个用户的分布,
# 最小值 8、p5=10有道题 8 个字符就能通过,于是它测的是"谁做过那道水题"
# 而不是"谁写得简洁"配不出有意义的成就2026-08-05 删除。
# 要重新引入,得先按题目难度加权,或排除掉那类水题。
@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()