"""钓鱼邮件鉴别 (Phishing Spot) — judge each email: phishing or legit? Rendered emails stream one at a time; the player calls each 钓鱼/正常 against the clock. The truth (is_phishing) + flags + explanation stay in the secret payload; the player gets sender/subject/body only and submits judgments. Balanced draw so it never degenerates into "flag everything". """ from __future__ import annotations import random from typing import Any from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.core.exceptions import AppException from app.domains.arcade.catalog import BuildResult, GradeResult from app.domains.arcade.content_models import STATUS_ACTIVE, ArcadePhishingSample from app.identity.person_models import IdentityPerson TIME_LIMIT_MS = 75_000 ITEM_COUNT = 8 MIN_BANK = 4 BASE_POINTS = 100 COMBO_STEP = 20 COMBO_CAP = 5 async def build(db: AsyncSession, person: IdentityPerson, variant: str | None = None) -> BuildResult: # noqa: ARG001 rng = random.SystemRandom() rows = list((await db.execute( select(ArcadePhishingSample).where(ArcadePhishingSample.status == STATUS_ACTIVE) )).scalars().all()) if len(rows) < MIN_BANK: raise AppException( code="ARCADE_GAME_UNAVAILABLE", message="The phishing sample bank is too small to start a round.", status_code=422, ) phish = [r for r in rows if r.is_phishing] legit = [r for r in rows if not r.is_phishing] n_each = max(1, ITEM_COUNT // 2) picked = ( rng.sample(phish, min(n_each, len(phish))) + rng.sample(legit, min(ITEM_COUNT - n_each, len(legit))) ) # Top up from whatever's left if one class was short. if len(picked) < min(ITEM_COUNT, len(rows)): rest = [r for r in rows if r not in picked] picked += rng.sample(rest, min(ITEM_COUNT - len(picked), len(rest))) rng.shuffle(picked) items_secret: list[dict[str, Any]] = [] items_public: list[dict[str, Any]] = [] for i, s in enumerate(picked): items_secret.append({ "index": i, "is_phishing": s.is_phishing, "flags": s.flags or [], "explanation": s.explanation, "sender_name": s.sender_name, "sender_email": s.sender_email, "subject": s.subject, "body": s.body, }) items_public.append({ "index": i, "sender_name": s.sender_name, "sender_email": s.sender_email, "subject": s.subject, "body": s.body, }) return BuildResult( payload={"items": items_secret}, public={"items": items_public, "item_count": len(items_public)}, ) def grade(payload: dict, submission: dict, elapsed_ms: int) -> GradeResult: """``submission``: ``{"judgments": [{"index", "phishing": bool}]}``.""" raw = submission.get("judgments") if isinstance(submission, dict) else None by_index: dict[int, bool] = {} if isinstance(raw, list): for j in raw: if not isinstance(j, dict): continue idx_raw = j.get("index") if idx_raw is None: continue try: idx = int(idx_raw) except (TypeError, ValueError): continue if idx not in by_index: by_index[idx] = bool(j.get("phishing")) score = 0 combo = 0 best_combo = 0 correct_count = 0 answered_count = 0 breakdown: list[dict[str, Any]] = [] for item in payload.get("items", []): idx = item["index"] answered = idx in by_index is_correct = answered and by_index[idx] == item["is_phishing"] gained = 0 if answered: answered_count += 1 if is_correct: correct_count += 1 combo += 1 best_combo = max(best_combo, combo) gained = BASE_POINTS + COMBO_STEP * min(combo - 1, COMBO_CAP) score += gained else: combo = 0 breakdown.append({ "index": idx, "sender_name": item["sender_name"], "sender_email": item["sender_email"], "subject": item["subject"], "is_phishing": item["is_phishing"], "your_call_phishing": by_index.get(idx), "is_correct": is_correct, "flags": item["flags"], "explanation": item["explanation"], "gained": gained, }) return GradeResult( score=score, detail={ "correct_count": correct_count, "answered_count": answered_count, "item_count": len(payload.get("items", [])), "best_combo": best_combo, "elapsed_ms": elapsed_ms, "breakdown": breakdown, }, ) __all__ = ["build", "grade", "TIME_LIMIT_MS", "ITEM_COUNT"]