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:
@@ -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:
|
||||||
|
|||||||
@@ -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 数字必须按整数比较,不能按字符串序。
|
||||||
|
|||||||
@@ -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", "最长代码行数", "提交过的最长代码有多少行")
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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 里的数字若不显式 cast,Postgres 按字符串序比较,`"9" > "50"` 成立。
|
- **JSONB 阈值比较按整数** —— 不 PASS 的话,以后在后台调低阈值触发的补发会发给错误的人群。JSONB 里的数字若不显式 cast,Postgres 按字符串序比较,`"9" > "50"` 成立。
|
||||||
|
|
||||||
### 第三步 单人试跑重算
|
### 第三步 单人试跑重算
|
||||||
|
|||||||
Reference in New Issue
Block a user