Files
web/docs/superpowers/specs/2026-03-16-ranking-zones-design.md
T
xuyueandClaude Opus 5.5 47d4679984 chore: 合并为单仓库,统一部署流程
- 根目录加入 docker-compose、Caddyfile、文档等原本不在仓库里的文件
- 前后端两份 GitHub Actions 合并成根目录的一个 deploy workflow:
  构建前端上传到 build/,再在服务器上 git pull 并重建容器

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 16:02:12 +08:00

7.9 KiB
Raw Blame History

Ranking Zones Page — Design Spec

Date: 2026-03-16 Status: Approved

Overview

A new standalone page that displays challenge submissions that students have explicitly nominated for ranking. Each nominated submission is placed into one of four score-based zones (Bayesian weighted score). Students choose which of their submissions enters the ranking; each student may nominate at most one submission per challenge.


1. Scoring: Bayesian Weighted Score (global — replaces existing score)

The existing score field is replaced with a Bayesian weighted score to prevent extreme scores from small numbers of raters distorting rankings. This affects the Submissions page, Leaderboard, My Scores, and the new Ranking page — all use Bayesian score.

Formula

bayesian_score = (C × m + n × raw_score) / (C + n)
  • raw_score = role-weighted average: (super×0.5 + admin×0.3 + normal×0.2) / n
  • m = global mean of raw_score across all submissions with at least one rating, excluding the current submission
  • n = number of raters on this submission
  • C = 3 (confidence parameter)

When n = 0: both score and raw_score are 0.

Model changes (api/submission/models.py)

Add raw_score field and add Avg import:

from django.db.models import Avg   # add to imports

raw_score = models.FloatField(default=0.0, verbose_name="原始加权分")

update_score() replacement

def update_score(self):
    ratings = list(self.ratings.select_related("user").all())
    n = len(ratings)

    if n == 0:
        self.raw_score = 0.0
        self.score = 0.0
        self.save(update_fields=["raw_score", "score"])
        return

    weighted_sum = sum(
        r.score * (0.5 if r.user.role == RoleChoices.SUPER
                   else 0.3 if r.user.role == RoleChoices.ADMIN
                   else 0.2)
        for r in ratings
    )
    self.raw_score = weighted_sum / n

    C = 3
    # Exclude self to avoid stale DB value skewing the mean
    global_mean = (
        Submission.objects.filter(raw_score__gt=0)
        .exclude(pk=self.pk)
        .aggregate(Avg("raw_score"))["raw_score__avg"]
    ) or self.raw_score  # fallback if this is the only ever-rated submission

    self.score = (C * global_mean + n * self.raw_score) / (C + n)
    self.save(update_fields=["raw_score", "score"])

Downstream note: Profile.recalculate_total_score() (called after every rating via signal) uses best-score-per-challenge. After this change it will use Bayesian score values — leaderboard totals will reflect Bayesian scores. This is the intended global effect.

Migration required: adds raw_score column (default 0.0, no backfill needed for existing rows).


2. Nomination: student opts one submission per challenge into ranking

Model changes (api/submission/models.py)

nominated = models.BooleanField(default=False, db_index=True, verbose_name="参与排名")

Constraint: at most one nominated submission per (user, task) pair — enforced in the API.

New endpoint (api/submission/api.py)

PUT /api/submission/{submission_id}/nominate
  • Login required; only the submission's owner may call it
  • Sets nominated=True on this submission
  • Sets nominated=False on all other submissions by the same user for the same task
  • Returns {"nominated": true}

Schema changes (api/submission/schemas.py)

Add nominated: bool = False to SubmissionOut:

nominated: bool = False

No resolver needed — nominated maps directly from the model field.

Frontend type (client/src/utils/type.ts)

Add to SubmissionOut interface:

nominated: boolean

Nomination UI (client/src/pages/Submissions.vue and/or submission result)

  • Add a "参与排名" button visible only to the submission's owner (row.userid === currentUser.id)
  • Nominated submission: show a 🏅 indicator on the row
  • Clicking calls Submission.nominate(id), then toggles nominated on the affected rows in data.value (set clicked row to true, others with same task_id and same user to false)

3. Zones

Only nominated=True submissions appear on the Ranking page. Zones by Bayesian score:

Zone Score Condition Sort Order
🏆 精华 score ≥ 4.5 score DESC
⭐ 优秀 3.5 ≤ score < 4.5 score DESC
📝 普通 0 < score < 3.5 score DESC
⏳ 待评 score = 0 created DESC

待评 zone uses score__lt=0.001 (not exact float equality) to safely capture unrated submissions. Minimum non-zero Bayesian score is well above 0.001 (single normal-role rater scoring 1 → raw=0.2 → Bayesian ≈ (3×m+0.2)/4).


4. Ranking Page layout

Split-pane (same as Submissions.vue): left 33% list, right 67% preview.

Left panel:

  • Zone tabs with count badges (fetched on mount, not refreshed after scoring)
  • Empty zone: n-empty placeholder
  • Columns: # · 得分 · 提交者 · 任务 · 时间
  • Pagination 10/page; resets to page 1 on zone switch

Right panel:

  • Preview component; @after-score updates only my_score on the row — zone reassignment waits for full reload (accepted)

5. Backend filter changes (api/submission/)

schemas.py — SubmissionFilter additions

score_min: Optional[float] = None
score_max_exclusive: Optional[float] = None
score_lt_threshold: Optional[float] = None   # replaces score_exact_zero; use 0.001 for 待评
nominated: Optional[bool] = None
ordering: Optional[str] = None   # "-score" | "score" | "-created"

api.py — list_submissions additions

if filters.nominated is not None:
    submissions = submissions.filter(nominated=filters.nominated)
if filters.score_lt_threshold is not None:
    submissions = submissions.filter(score__lt=filters.score_lt_threshold)
else:
    if filters.score_min is not None:
        submissions = submissions.filter(score__gte=filters.score_min)
    if filters.score_max_exclusive is not None:
        submissions = submissions.filter(score__lt=filters.score_max_exclusive)
if filters.ordering in ("-score", "score", "-created"):
    submissions = submissions.order_by(filters.ordering)

Existing callers pass none of these params — unaffected.

Zone → API param mapping

Zone Params
精华 task_type=challenge, nominated=true, score_min=4.5, ordering=-score
优秀 task_type=challenge, nominated=true, score_min=3.5, score_max_exclusive=4.5, ordering=-score
普通 task_type=challenge, nominated=true, score_min=0.001, score_max_exclusive=3.5, ordering=-score
待评 task_type=challenge, nominated=true, score_lt_threshold=0.001, ordering=-created

6. Frontend API changes (client/src/api.ts)

Updated Submission.list signature (adds new params, preserves existing ones):

async list(query: {
  page: number
  username?: string
  flag?: string | null
  task_type?: string
  score_min?: number
  score_max_exclusive?: number
  score_lt_threshold?: number
  nominated?: boolean
  ordering?: string
})

New method:

async nominate(id: string) {
  const res = await http.put(`/submission/${id}/nominate`)
  return res.data
}

7. New file: client/src/pages/Ranking.vue

  • Auth-guarded (meta: { auth: true })
  • Zone tab + per-zone page state; default zone: 精华
  • 4 parallel count-badge calls on mount
  • Selected submission → Preview

8. Router & nav

// router.ts
{ path: '/ranking', component: () => import('./pages/Ranking.vue'), meta: { auth: true } }

Add "排名" link in existing nav.


9. Database migration

Adds raw_score (FloatField, default=0.0) and nominated (BooleanField, default=False) to submission_submission.


Out of Scope

  • Per-challenge filtering on Ranking page
  • Withdrawing from ranking without re-nominating
  • Refreshing zone counts after inline scoring
  • Tutorial submissions (fixed task_type=challenge on all Ranking page requests)