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>
This commit is contained in:
2026-08-05 11:02:16 -06:00
parent c50f7b7269
commit e9c7793714
5 changed files with 63 additions and 30 deletions

View File

@@ -31,7 +31,7 @@ def evaluate(user, metrics, only_metrics=None):
for achievement in qs: for achievement in qs:
value = metrics.get(achievement.metric) value = metrics.get(achievement.metric)
# 指标从未产生有效值时 key 不存在,直接跳过: # 指标从未产生有效值时 key 不存在,直接跳过:
# 否则 min_ac_code_chars 这类极小值指标会对新用户恒成立 # 否则极小值型指标(求 min、配 lte 用的那种)会对新用户恒成立
if value is None: if value is None:
continue continue
if achievement.operator == Operator.GTE and value >= achievement.threshold: if achievement.operator == Operator.GTE and value >= achievement.threshold:

View File

@@ -16,7 +16,7 @@ from django.db.models.functions import Cast
from account.models import User from account.models import User
from achievement.metrics import META_METRICS, METRIC_REGISTRY from achievement.metrics import META_METRICS, METRIC_REGISTRY
from achievement.models import Achievement, UserAchievement, UserStat from achievement.models import Achievement, Operator, UserAchievement, UserStat
# on_submission 依赖的辅助键 -> 它应该与哪个顶层指标保持一致 # on_submission 依赖的辅助键 -> 它应该与哪个顶层指标保持一致
STATE_PAIRS = { STATE_PAIRS = {
@@ -85,20 +85,20 @@ class Command(BaseCommand):
if count == 0: if count == 0:
self._fail(title, "METRIC_REGISTRY 是空的AppConfig.ready() 没有导入 metrics") self._fail(title, "METRIC_REGISTRY 是空的AppConfig.ready() 没有导入 metrics")
return return
if "min_ac_code_chars" not in METRIC_REGISTRY:
self._fail(title, f"注册了 {count} 个指标,但缺少 min_ac_code_chars")
return
self._ok(title, f"{count} 个指标,元指标 {sorted(META_METRICS)}") self._ok(title, f"{count} 个指标,元指标 {sorted(META_METRICS)}")
def _check_min_metric_absent(self): def _check_min_metric_absent(self):
"""极小值型指标对没有 AC 记录的用户必须返回 None。 """lte 类成就用的指标对没有 AC 记录的用户必须返回 None。
返回 0 的话,"最短 AC 代码 ≤ 50 字符"会白送给每一个从没做出过题的新生。 返回 0 的话,"最短 AC 代码 ≤ 50 字符"这类成就会白送给每一个从没做出过题的新生。
线上目前没有 lte 成就min_ac_code_chars 已删),本项因此会 SKIP
将来配了任何 lte 成就,它会自动开始检查对应的指标。
""" """
title = "极小值指标对零 AC 用户返回 None" title = "极小值指标对零 AC 用户返回 None"
metric = METRIC_REGISTRY.get("min_ac_code_chars") metric_keys = sorted(set(Achievement.objects.filter(operator=Operator.LTE, visible=True).values_list("metric", flat=True)))
if metric is None: metrics = [(k, METRIC_REGISTRY[k]) for k in metric_keys if k in METRIC_REGISTRY]
self._skip(title, "指标未注册") if not metrics:
self._skip(title, "没有上架的 lte 类成就")
return return
user = User.objects.filter(is_disabled=False, userprofile__accepted_number=0).first() user = User.objects.filter(is_disabled=False, userprofile__accepted_number=0).first()
@@ -106,14 +106,15 @@ class Command(BaseCommand):
self._skip(title, "找不到 accepted_number=0 的用户,无法验证") self._skip(title, "找不到 accepted_number=0 的用户,无法验证")
return return
value = metric.recompute(user) bad = [(key, value) for key, m in metrics if (value := m.recompute(user)) is not None]
if value is None: if bad:
self._ok(title, f"用户 {user.username}") detail = "".join(f"{key} 得到 {value!r}" for key, value in bad)
else:
self._fail( self._fail(
title, title,
f"用户 {user.username} 得到 {value!r},应为 None。\n 现在配任何 lte 成就都会白送给全部零 AC 用户。", f"用户 {user.username}{detail}应为 None。\n 这些 lte 成就正在白送给全部零 AC 用户。",
) )
else:
self._ok(title, f"用户 {user.username},检查了 {len(metrics)} 个指标")
def _check_jsonb_cast(self): def _check_jsonb_cast(self):
"""JSONB 数字必须按整数比较,不能按字符串序。 """JSONB 数字必须按整数比较,不能按字符串序。

View File

@@ -14,6 +14,7 @@ from django.db.models import Count, Q
from django.utils import timezone from django.utils import timezone
from submission.models import JudgeStatus, Submission, is_accepted from submission.models import JudgeStatus, Submission, is_accepted
from utils.constants import Difficulty
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -81,6 +82,16 @@ def build_ctx(user_id, sub):
) )
local_now = timezone.localtime(sub.create_time) local_now = timezone.localtime(sub.create_time)
sub_is_accepted = is_accepted(sub.result) 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 { return {
"skip": False, "skip": False,
"is_accepted": sub_is_accepted, "is_accepted": sub_is_accepted,
@@ -88,9 +99,11 @@ def build_ctx(user_id, sub):
"prior_count": prior_stats["total"], "prior_count": prior_stats["total"],
"prior_accepted": prior_stats["accepted"], "prior_accepted": prior_stats["accepted"],
# 首次 AC 这道题(此前从未 AC 过) # 首次 AC 这道题(此前从未 AC 过)
"is_first_ac_of_problem": sub_is_accepted and prior_stats["accepted"] == 0, "is_first_ac_of_problem": is_first_ac_of_problem,
# 一发入魂:此前无任何提交且本次 AC # 一发入魂:此前无任何提交且本次 AC
"is_first_try_ac": sub_is_accepted and prior_stats["total"] == 0, "is_first_try_ac": sub_is_accepted and prior_stats["total"] == 0,
# 本题难度,仅首次 AC 时有值
"problem_difficulty": difficulty,
"local_date": local_now.date().isoformat(), "local_date": local_now.date().isoformat(),
"local_hour": local_now.hour, "local_hour": local_now.hour,
} }
@@ -106,6 +119,33 @@ class AcceptedCount(BaseMetric):
return _practice_submissions(user.id).filter(result__in=ACCEPTED_RESULTS).order_by().values("problem_id").distinct().count() 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", "提交总数", "提交次数(不含比赛)") @metric("submission_count", "提交总数", "提交次数(不含比赛)")
class SubmissionCount(BaseMetric): class SubmissionCount(BaseMetric):
def on_submission(self, metrics, sub, ctx): def on_submission(self, metrics, sub, ctx):
@@ -328,18 +368,10 @@ class MaxAcInOneDay(BaseMetric):
return {"_ac_per_day": counts} return {"_ac_per_day": counts}
@metric("min_ac_code_chars", "最短 AC 代码", "通过的代码里最短的字符数(配小于等于使用)") # 曾经这里有 min_ac_code_chars最短 AC 代码)。线上实测 1314 个用户的分布,
class MinAcCodeChars(BaseMetric): # 最小值 8、p5=10有道题 8 个字符就能通过,于是它测的是"谁做过那道水题"
def on_submission(self, metrics, sub, ctx): # 而不是"谁写得简洁"配不出有意义的成就2026-08-05 删除。
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", "最长代码行数", "提交过的最长代码有多少行") @metric("max_code_lines", "最长代码行数", "提交过的最长代码有多少行")

View File

@@ -43,7 +43,7 @@ class UserStat(models.Model):
"""成就系统唯一的指标源,不复用 UserProfile 的计数器,避免两处口径漂移。 """成就系统唯一的指标源,不复用 UserProfile 的计数器,避免两处口径漂移。
metrics 为 {指标名: 数值};指标从未产生过有效值时 key 不存在(而非置 0 metrics 为 {指标名: 数值};指标从未产生过有效值时 key 不存在(而非置 0
否则 min_ac_code_chars 这类极小值指标会对新用户恒成立。 否则极小值型指标(求 min、配 lte 用的那种)会对新用户恒成立。
""" """
user = models.OneToOneField(User, on_delete=models.CASCADE, related_name="achievement_stat") user = models.OneToOneField(User, on_delete=models.CASCADE, related_name="achievement_stat")

View File

@@ -40,7 +40,7 @@ python manage.py check_achievement_deploy
只读,可反复跑,七项检查。此刻大部分是 `[SKIP]`(还没配成就、还没重算),**但这两项必须现在就 PASS** 只读,可反复跑,七项检查。此刻大部分是 `[SKIP]`(还没配成就、还没重算),**但这两项必须现在就 PASS**
- **极小值指标对零 AC 用户返回 None** —— 不 PASS 就别配任何 `lte` 类成就。`min_ac_code_chars` 若返回 `0`,「最短 AC 代码 ≤ 50 字符」会白送给每一个从没做出过题的新生。 - **极小值指标对零 AC 用户返回 None** —— 不 PASS 就别配任何 `lte` 类成就。这类指标若返回 `0`,「最短 AC 代码 ≤ 50 字符」之流会白送给每一个从没做出过题的新生。线上目前没有 `lte` 成就,本项会 SKIP配了才开始检查。
- **JSONB 阈值比较按整数** —— 不 PASS 的话以后在后台调低阈值触发的补发会发给错误的人群。JSONB 里的数字若不显式 castPostgres 按字符串序比较,`"9" > "50"` 成立。 - **JSONB 阈值比较按整数** —— 不 PASS 的话以后在后台调低阈值触发的补发会发给错误的人群。JSONB 里的数字若不显式 castPostgres 按字符串序比较,`"9" > "50"` 成立。
### 第三步 单人试跑重算 ### 第三步 单人试跑重算