"""채점 로직 — docs/SCORING.md (SSOT) 그대로 구현. 배점: 정확 스코어 5 · 근접(승패+득실차) 3 · 승패 2 · 부분(한 팀 득점 일치) 1 · 빗나감 0. 단조 증가(정확>근접>승패>부분>빗나감). 배점·배제 대상은 backend/data/scoring.json 에서 기동 시 로드(없으면 아래 기본값). """ from __future__ import annotations import json import logging from pathlib import Path log = logging.getLogger("triplepick.scoring") # 기본 배점 — backend/data/scoring.json 로드 시 덮어씀 (docs/SCORING.md §2) SCORE = {"EXACT": 5, "CLOSE": 3, "OUTCOME": 2, "PARTIAL": 1, "MISS": 0} def outcome_of(score_a: int, score_b: int) -> str: if score_a > score_b: return "TEAM_A_WIN" if score_a < score_b: return "TEAM_B_WIN" return "DRAW" def score_prediction( pick_a: int, pick_b: int, result_a: int, result_b: int ) -> int: """내 예측 vs 실제 결과 → 적중 포인트 (결정론적).""" if pick_a == result_a and pick_b == result_b: return SCORE["EXACT"] if outcome_of(pick_a, pick_b) == outcome_of(result_a, result_b): if (pick_a - pick_b) == (result_a - result_b): return SCORE["CLOSE"] return SCORE["OUTCOME"] if pick_a == result_a or pick_b == result_b: return SCORE["PARTIAL"] return SCORE["MISS"] # ── 자격 · 배제 (docs/SCORING.md §4, P1 구현) ────────────────── STAFF_EMAILS: set[str] = set() # 운영진/임직원 블록리스트 STAFF_DOMAINS = ["o2o.kr", "aio2o.kr"] def is_excluded(email: str) -> bool: e = email.strip().lower() return e in STAFF_EMAILS or any(e.endswith("@" + d) for d in STAFF_DOMAINS) # ── 외부 설정 로드 (backend/data/scoring.json, key-value) ────── DATA_FILE = Path(__file__).resolve().parent.parent / "data" / "scoring.json" # 등급 → scoring.json 의 key _JSON_KEY = { "EXACT": "score_exact", "CLOSE": "score_close", "OUTCOME": "score_outcome", "PARTIAL": "score_partial", "MISS": "score_miss", } def grade_prediction( pick_a: int, pick_b: int, result_a: int, result_b: int, path: Path | None = None, ) -> tuple[str, int]: """유저 투표(픽) vs 실제 결과 → scoring.json 에서 해당 등급의 (key, value). score_prediction 과 같은 판정 기준. 예: 승패+득실차 일치 → ("score_close", 3). scoring.json 이 없거나 키가 빠지면 기본 SCORE 값으로 대체. """ p = path or DATA_FILE try: data = json.loads(p.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError) as e: log.warning("scoring data 읽기 실패 (%s): %s — 기본 배점 사용", p, e) data = {} if pick_a == result_a and pick_b == result_b: grade = "EXACT" elif outcome_of(pick_a, pick_b) == outcome_of(result_a, result_b): grade = "CLOSE" if (pick_a - pick_b) == (result_a - result_b) else "OUTCOME" elif pick_a == result_a or pick_b == result_b: grade = "PARTIAL" else: grade = "MISS" key = _JSON_KEY[grade] value = data[key] if isinstance(data.get(key), int) else SCORE[grade] return key, value _SCORE_KEYS = { "score_exact": "EXACT", "score_close": "CLOSE", "score_outcome": "OUTCOME", "score_partial": "PARTIAL", "score_miss": "MISS", } def load_scoring_data(path: Path | None = None) -> dict: """data/scoring.json 을 읽어 배점(SCORE)·배제 대상을 갱신. 기동 시 1회 호출. SCORE/STAFF_EMAILS/STAFF_DOMAINS 는 타 모듈이 import 한 객체라 재할당 대신 in-place 갱신. 파일이 없거나 깨져도 기본값으로 동작(부팅 실패 방지). """ p = path or DATA_FILE try: data = json.loads(p.read_text(encoding="utf-8")) except FileNotFoundError: log.warning("scoring data 없음 (%s) — 기본 배점 사용", p) return {} except (OSError, json.JSONDecodeError) as e: log.warning("scoring data 읽기 실패 (%s): %s — 기본 배점 사용", p, e) return {} for key, score_key in _SCORE_KEYS.items(): if isinstance(data.get(key), int): SCORE[score_key] = data[key] if isinstance(data.get("excluded_emails"), list): STAFF_EMAILS.clear() STAFF_EMAILS.update(e.strip().lower() for e in data["excluded_emails"]) if isinstance(data.get("excluded_domains"), list): STAFF_DOMAINS[:] = [ d.strip().lower().lstrip("@") for d in data["excluded_domains"] ] log.info( "scoring data 로드 (%s): 배점 %s · 배제 이메일 %d · 도메인 %s", p, SCORE, len(STAFF_EMAILS), STAFF_DOMAINS, ) return data