"""成就徽章 catalog — code-defined badge specs, their unlock criteria and the progress metric each one is measured on. The catalog is code (not a table) because each badge's criterion IS code logic evaluated against a per-person ``facts`` dict (see service.compute_facts_for): tenure years, quiz streak, cloud grants, devices ever held, self-service requests, approvals handled, community activity … Adding a badge = appending a spec here (+ its i18n keys in all three bundles). Ordering inside a category is the display order on the 成就 wall. Two things every spec carries beyond the old (code, icon, category) triple: * ``tier`` — 稀有度. Drives the frontend's ring/accent treatment and lets the wall sort "hard to get" apart from "everyone has it". Purely presentational; it never affects whether a badge unlocks. * ``metric`` + ``threshold`` — the fact key and target the badge is measured on. This is what makes *progress* possible ("安全连胜 5/7 天") instead of a wall of opaque grey squares. A badge whose criterion is not a simple "counter reaches N" passes an explicit ``predicate`` instead and reports no numeric progress. ``validate_catalog()`` runs at import: a spec with neither a predicate nor a (metric, threshold) pair, an unknown tier/category, or a duplicate code is a hard error rather than a badge that silently never unlocks. """ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass from typing import Any # ── Tiers (稀有度) ──────────────────────────────────────────────────── # Presentation-only ladder. Roughly: common = most people will get it just by # working here; rare = takes deliberate activity; epic = sustained activity or # real seniority; legendary = a handful of people, org-wide. TIER_COMMON = "common" TIER_RARE = "rare" TIER_EPIC = "epic" TIER_LEGENDARY = "legendary" TIERS: tuple[str, ...] = (TIER_COMMON, TIER_RARE, TIER_EPIC, TIER_LEGENDARY) # ── Categories ─────────────────────────────────────────────────────── # The wall groups by these, in this order. Each needs # ``achievements.category.`` in every locale bundle. CATEGORIES: tuple[str, ...] = ( "tenure", # 同行时光 — joining, onboarding, anniversaries "assets", # 装备 — devices held / returned / inventory "software", # 软件与工具 — seats, AI tools, API keys "cloud", # 云端 — cloud access breadth "security", # 安全 — security quiz streaks and ranks "compliance", # 合规 — access self-attestations "workflow", # 自助与审批 — self-service requests, approvals handled "community", # 社区与互助 — feed activity, blessings, self-service help "arcade", # 放松一下 — the security arcade ) @dataclass(frozen=True) class Badge: code: str icon: str category: str tier: str #: Fact key this badge is measured on. ``None`` for predicate-only badges. metric: str | None = None #: Target value of ``metric`` at which the badge unlocks. threshold: float | None = None #: Escape hatch for criteria that are not "counter reaches N". When set it #: wins over metric/threshold for the *earned* decision. predicate: Callable[[dict], bool] | None = None #: Unit the progress pair is measured in, when the bare numbers are #: ambiguous. ``"years"`` on the anniversary badges: a unitless #: ``1511/1825`` next to "32% 的同事拥有" was read as a people count. #: ``None`` for counters whose unit is obvious from the badge desc. unit: str | None = None @property def name_key(self) -> str: return f"badge.{self.code}.name" @property def desc_key(self) -> str: return f"badge.{self.code}.desc" def is_earned(self, facts: dict) -> bool: """Whether ``facts`` satisfies this badge's criterion.""" if self.predicate is not None: return bool(self.predicate(facts)) return _num(facts.get(self.metric)) >= float(self.threshold or 0) def progress(self, facts: dict) -> dict | None: """``{current, target, percent}`` toward this badge, or ``None`` for a predicate-only badge (nothing meaningful to count toward). ``current`` is clamped to ``target`` so an over-achiever never renders a 340% bar, and ``percent`` is an int 0–100 for the frontend meter. """ if self.metric is None or self.threshold is None: return None target = float(self.threshold) current = _num(facts.get(self.metric)) capped = min(current, target) if target <= 0: percent = 100 else: raw = 100 * capped / target # Floor, not round: 6.99/7 rounds to 100 and renders a grey, # locked badge as "100%". Only an actually-met threshold reads 100. percent = 100 if capped >= target else min(99, int(raw)) out: dict[str, Any] = { "current": _clean(capped), "target": _clean(target), "percent": percent, } if self.unit is not None: out["unit"] = self.unit return out def _num(value: object) -> float: """Facts may carry ``None`` (e.g. tenure_years for a person with no hire date) — treat that as 0 rather than raising inside a predicate.""" if value is None or value is False: return 0.0 if value is True: return 1.0 try: return float(value) # type: ignore[arg-type] except (TypeError, ValueError): return 0.0 def _clean(value: float) -> float | int: """Render whole numbers as ints so the wall shows ``3/5`` not ``3.0/5.0``.""" return int(value) if float(value).is_integer() else round(value, 2) # Order = display order within a category. Keep codes stable — they are the # unlock key persisted in gamification_unlocks. BADGES: list[Badge] = [ # ── 同行时光 ───────────────────────────────────────────────────── # ``first_day`` is deliberately trivially earned: a brand-new joiner # opening the wall on day 1 should see one lit badge and a set of # in-progress bars, not 30 grey squares. # # Anniversary thresholds are calendar YEARS on the ``tenure_years`` fact # (``core.tenure.completed_years`` + fraction), NOT ``365 × N`` days: the # day caliber unlocked 1-2 days early across leap years and disagreed with # every other tenure surface (footprint, wrapped, HR 洞察司龄分布). Badge("first_day", "🚀", "tenure", TIER_COMMON, predicate=lambda f: f.get("tenure_years") is not None), Badge("onboarding_done", "🧭", "tenure", TIER_COMMON, "onboarding_ack_complete", 1), Badge("anniversary_1y", "🎂", "tenure", TIER_COMMON, "tenure_years", 1, unit="years"), Badge("anniversary_3y", "🎉", "tenure", TIER_RARE, "tenure_years", 3, unit="years"), Badge("anniversary_5y", "🏛️", "tenure", TIER_EPIC, "tenure_years", 5, unit="years"), Badge("anniversary_7y", "🌳", "tenure", TIER_EPIC, "tenure_years", 7, unit="years"), Badge("anniversary_10y", "💎", "tenure", TIER_LEGENDARY, "tenure_years", 10, unit="years"), # ── 装备 ───────────────────────────────────────────────────────── Badge("first_device", "📦", "assets", TIER_COMMON, "devices_ever", 1), Badge("collector", "🎒", "assets", TIER_RARE, "devices_ever", 3), Badge("asset_tycoon", "🏆", "assets", TIER_EPIC, "devices_ever", 6), Badge("careful_keeper", "🤝", "assets", TIER_RARE, "devices_returned", 1), Badge("inventory_diligent", "📋", "assets", TIER_RARE, "inventory_confirmations", 3), # ── 安全宣贯 ───────────────────────────────────────────────────── # Counted on the ``detail`` surface only — being shown a card in the # ambient bubble is not having read the thing, and a badge that # rewarded passive exposure would reward leaving a tab open. Badge("awareness_reader", "📖", "security", TIER_COMMON, "awareness_materials_read", 1), Badge("awareness_regular", "📚", "security", TIER_RARE, "awareness_materials_read", 5), Badge("awareness_scholar", "🎓", "security", TIER_EPIC, "awareness_materials_read", 15), # Passing a material's quiz is a stronger claim than opening it, so # it gets its own ladder rather than a higher threshold on the same # counter — "read 15" and "understood 3" are different achievements. Badge("awareness_quiz_first", "✅", "security", TIER_COMMON, "awareness_quizzes_passed", 1), Badge("awareness_quiz_five", "🧠", "security", TIER_RARE, "awareness_quizzes_passed", 5), # ── 软件与工具 ─────────────────────────────────────────────────── Badge("software_first", "🧩", "software", TIER_COMMON, "seats_active", 1), Badge("toolbox", "🛠️", "software", TIER_RARE, "seats_active", 5), Badge("ai_pioneer", "🤖", "software", TIER_COMMON, "ai_tools_approved", 1), Badge("automator", "⚙️", "software", TIER_RARE, "api_keys_held", 1), # ── 云端 ───────────────────────────────────────────────────────── Badge("cloud_explorer", "☁️", "cloud", TIER_COMMON, "cloud_grants", 1), Badge("cloud_navigator", "🧭", "cloud", TIER_RARE, "cloud_grants", 3), Badge("cloud_master", "🛰️", "cloud", TIER_EPIC, "cloud_grants", 10), # ── 安全 ───────────────────────────────────────────────────────── Badge("streak_7", "🛡️", "security", TIER_COMMON, "longest_streak", 7), Badge("streak_30", "🔥", "security", TIER_RARE, "longest_streak", 30), Badge("streak_100", "⚡", "security", TIER_LEGENDARY, "longest_streak", 100), Badge("rank_gold", "🏅", "security", TIER_COMMON, "total_correct", 15), Badge("rank_master", "👑", "security", TIER_EPIC, "total_correct", 100), # ── 合规 ───────────────────────────────────────────────────────── Badge("attest_first", "✅", "compliance", TIER_COMMON, "attestations_done", 1), Badge("attest_regular", "🧾", "compliance", TIER_RARE, "attestations_done", 3), # ── 自助与审批 ─────────────────────────────────────────────────── # Approver thresholds are set against what the in-Helix decider columns can # actually reach (Feishu-decided approvals never record an approver id), so # 50 — not a round 100 — is the top of this ladder. Badge("first_request", "📝", "workflow", TIER_COMMON, "requests_submitted", 1), Badge("request_regular", "🧰", "workflow", TIER_RARE, "requests_submitted", 10), Badge("approver_active", "⚖️", "workflow", TIER_RARE, "approvals_handled", 10), Badge("approver_veteran", "🏛", "workflow", TIER_EPIC, "approvals_handled", 50), # ── 社区与互助 ─────────────────────────────────────────────────── Badge("first_post", "💬", "community", TIER_COMMON, "feed_posts", 1), Badge("community_voice", "📣", "community", TIER_RARE, "feed_posts", 10), Badge("warm_heart", "💛", "community", TIER_RARE, "blessings_sent", 10), Badge("popular", "🌟", "community", TIER_EPIC, "feed_likes_received", 25), Badge("self_helper", "🔍", "community", TIER_RARE, "support_deflections", 3), # ── 放松一下 (arcade) ──────────────────────────────────────────── # Count/title thresholds (not fragile score guesses) so they stay # meaningful. ``arcade_*`` facts default to 0 for people who never played. Badge("arcade_first", "🎮", "arcade", TIER_COMMON, "arcade_plays", 1), Badge("arcade_regular", "🕹️", "arcade", TIER_RARE, "arcade_plays", 20), Badge("scholar_jinshi", "🎓", "arcade", TIER_EPIC, "knowledge_correct", 200), Badge("scholar_zhuangyuan", "🀄", "arcade", TIER_LEGENDARY, "knowledge_correct", 680), ] BADGES_BY_CODE: dict[str, Badge] = {b.code: b for b in BADGES} def validate_catalog(badges: list[Badge] | None = None) -> None: """Fail loudly at import on a malformed spec. A badge with neither a predicate nor a (metric, threshold) pair would never unlock and never show progress — a silent dead entry on everyone's wall. An unknown category would drop it out of the grouped UI entirely. Both are the kind of typo that only surfaces as "why does nobody have this badge?" weeks later, so they are hard errors here. """ from app.domains.gamification.facts import FACT_KEYS seen: set[str] = set() for b in badges if badges is not None else BADGES: if b.code in seen: raise ValueError(f"duplicate badge code: {b.code}") seen.add(b.code) if b.tier not in TIERS: raise ValueError(f"badge {b.code}: unknown tier {b.tier!r}") if b.category not in CATEGORIES: raise ValueError(f"badge {b.code}: unknown category {b.category!r}") if b.predicate is None and (b.metric is None or b.threshold is None): raise ValueError( f"badge {b.code}: needs either a predicate or metric+threshold", ) # A metric that no fact produces would leave the badge permanently at # 0 progress and permanently locked — the exact failure mode this # catalog is meant to make impossible. if b.metric is not None and b.metric not in FACT_KEYS: raise ValueError(f"badge {b.code}: unknown metric {b.metric!r}") validate_catalog() __all__ = [ "BADGES", "BADGES_BY_CODE", "CATEGORIES", "TIERS", "Badge", "validate_catalog", ]