141 lines
5.1 KiB
Python
141 lines
5.1 KiB
Python
"""채점 로직 — docs/SCORING.md (SSOT) 그대로 구현.
|
|
|
|
배점: 정확 스코어 5 · 근접(승패+득실차) 3 · 승패 2 · 부분(한 팀 득점 일치) 1 · 빗나감 0.
|
|
단조 증가(정확>근접>승패>부분>빗나감).
|
|
야구(KBO/MLB)는 득점 범위가 넓어 같은 등급 체계에 판정만 완화한다:
|
|
근접 = 승패 + 득실차 오차 ±1, 부분 = 한 팀 득점 오차 ±1.
|
|
배점·배제 대상은 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"
|
|
|
|
|
|
# 야구 판정 허용 오차 — 근접(득실차)·부분(한 팀 득점) 공통 ±1
|
|
BASEBALL_TOLERANCE = 1
|
|
|
|
|
|
def _grade_of(
|
|
pick_a: int, pick_b: int, result_a: int, result_b: int, baseball: bool = False
|
|
) -> str:
|
|
"""등급 판정 (단일 SSOT) — baseball 이면 근접/부분 오차 ±1 허용."""
|
|
tol = BASEBALL_TOLERANCE if baseball else 0
|
|
if pick_a == result_a and pick_b == result_b:
|
|
return "EXACT"
|
|
if outcome_of(pick_a, pick_b) == outcome_of(result_a, result_b):
|
|
if abs((pick_a - pick_b) - (result_a - result_b)) <= tol:
|
|
return "CLOSE"
|
|
return "OUTCOME"
|
|
if abs(pick_a - result_a) <= tol or abs(pick_b - result_b) <= tol:
|
|
return "PARTIAL"
|
|
return "MISS"
|
|
|
|
|
|
def score_prediction(
|
|
pick_a: int, pick_b: int, result_a: int, result_b: int, baseball: bool = False
|
|
) -> int:
|
|
"""내 예측 vs 실제 결과 → 적중 포인트 (결정론적)."""
|
|
return SCORE[_grade_of(pick_a, pick_b, result_a, result_b, baseball)]
|
|
|
|
|
|
# ── 자격 · 배제 (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,
|
|
baseball: bool = False,
|
|
) -> tuple[str, int]:
|
|
"""유저 투표(픽) vs 실제 결과 → scoring.json 에서 해당 등급의 (key, value).
|
|
|
|
score_prediction 과 같은 판정 기준(_grade_of 공유). 예: 승패+득실차 일치
|
|
→ ("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 = {}
|
|
|
|
grade = _grade_of(pick_a, pick_b, result_a, result_b, baseball)
|
|
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
|