From 30bc627ae709ee1853f3ba475a14797dff92bcea Mon Sep 17 00:00:00 2001 From: gahusb Date: Mon, 11 May 2026 08:33:51 +0900 Subject: [PATCH] =?UTF-8?q?feat(lotto):=20grade=5Fweekly=5Freview=20?= =?UTF-8?q?=ED=86=B5=ED=95=A9=20=EC=9E=A1=20=E2=80=94=20=ED=81=90=EB=A0=88?= =?UTF-8?q?=EC=9D=B4=ED=84=B0=20=EC=9E=90=EA=B8=B0=ED=8F=89=EA=B0=80=20+?= =?UTF-8?q?=20=ED=8C=A8=ED=84=B4=20=EA=B0=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lotto/app/jobs/grade_weekly_review.py | 154 ++++++++++++++++++++++++ lotto/tests/test_grade_weekly_review.py | 60 +++++++++ 2 files changed, 214 insertions(+) create mode 100644 lotto/app/jobs/grade_weekly_review.py create mode 100644 lotto/tests/test_grade_weekly_review.py diff --git a/lotto/app/jobs/grade_weekly_review.py b/lotto/app/jobs/grade_weekly_review.py new file mode 100644 index 0000000..98b8acc --- /dev/null +++ b/lotto/app/jobs/grade_weekly_review.py @@ -0,0 +1,154 @@ +"""주간 회고 채점 통합 잡 — 일요일 03:00 KST 실행. + +1) 기존 purchase_manager.check_purchases_for_draw() 로 사용자 구매 자동 채점 +2) 큐레이터 4계층 picks vs 추첨 결과 비교 +3) 패턴 요약·갭 계산 +4) weekly_review UPSERT +5) 4등 이상 발견 시 agent-office webhook 호출 +""" +import json +import logging +import os +from typing import Optional + +import httpx + +from .. import db +from ..purchase_manager import check_purchases_for_draw +from .grading_helpers import ( + score_picks_against_draw, + summarize_pattern, + aggregate_pattern_summaries, + compute_pattern_delta, +) + +logger = logging.getLogger("lotto-backend") + +AGENT_OFFICE_URL = os.environ.get("AGENT_OFFICE_URL", "http://agent-office:8000") + + +def _flatten_curator_picks(briefing: dict) -> list: + """4계층 picks 를 모두 합쳐 단일 리스트(score 계산용).""" + picks = briefing.get("picks") or {} + if isinstance(picks, list): + return picks + out = [] + for tier in ("core", "bonus", "extended", "pool"): + out.extend(picks.get(tier) or []) + return out + + +def _curator_score(briefing: dict, win_nums: list, bonus: int) -> dict: + if not briefing: + return {} + flat = _flatten_curator_picks(briefing) + if not flat: + return {} + return score_picks_against_draw(flat, win_nums, bonus) + + +def _user_score(drw_no: int, win_nums: list) -> dict: + purchases = db.get_purchases(draw_no=drw_no) + if not purchases: + return {} + matches = [] + win_set = set(win_nums) + pattern_summaries = [] + for p in purchases: + for nums in (p.get("numbers") or []): + if not nums: + continue + m = len(set(nums) & win_set) + matches.append(m) + pattern_summaries.append(summarize_pattern(nums)) + if not matches: + return {} + return { + "avg_match": round(sum(matches) / len(matches), 2), + "best_match": max(matches), + "five_plus_prizes": sum(1 for m in matches if m >= 3), + "pattern_avg": aggregate_pattern_summaries(pattern_summaries), + } + + +def _trigger_prize_alert(drw_no: int, match_count: int, numbers: list, purchase_id: int) -> None: + try: + with httpx.Client(timeout=10) as client: + client.post( + f"{AGENT_OFFICE_URL}/api/agent-office/notify/lotto-prize", + json={ + "draw_no": drw_no, + "match_count": match_count, + "numbers": numbers, + "purchase_id": purchase_id, + }, + ) + except Exception as e: + logger.warning(f"[grade_weekly_review] prize alert webhook failed: {e}") + + +def run_weekly_grading(drw_no: int) -> dict: + """주어진 회차에 대해 채점 잡 1회 실행. 멱등.""" + draw = db.get_draw(drw_no) + if not draw: + logger.warning(f"[grade_weekly_review] draw {drw_no} not found, skip") + return {"ok": False, "reason": "no draw"} + + win_nums = [draw["n1"], draw["n2"], draw["n3"], draw["n4"], draw["n5"], draw["n6"]] + bonus = draw["bonus"] + + # 1) 사용자 구매 자동 채점 (기존 인프라) + try: + check_purchases_for_draw(drw_no) + except Exception as e: + logger.warning(f"[grade_weekly_review] check_purchases_for_draw failed: {e}") + + # 2) 4등 이상 발견 시 webhook + purchases = db.get_purchases(draw_no=drw_no, checked=True) + for p in purchases: + for r in (p.get("results") or []): + if r.get("correct", 0) >= 4: + _trigger_prize_alert(drw_no, r["correct"], r["numbers"], p["id"]) + + # 3) 큐레이터 자기 평가 + briefing = db.get_briefing(drw_no) + cur = _curator_score(briefing, win_nums, bonus) + + # 4) 사용자 평가 (재로드, 구매가 다 채점된 후 패턴 계산) + usr = _user_score(drw_no, win_nums) + + # 5) 추첨 패턴 요약 + 델타 + draw_summary = summarize_pattern(win_nums) + draw_pattern = { + "low_avg": draw_summary["low_count"], + "odd_avg": draw_summary["odd_count"], + "sum_avg": draw_summary["sum"], + } + user_pattern = usr.get("pattern_avg", {}) + delta = compute_pattern_delta(user_pattern, draw_pattern) if user_pattern else "" + + # 6) UPSERT + payload = { + "draw_no": drw_no, + "curator_avg_match": cur.get("avg_match"), + "curator_best_tier": cur.get("best_tier"), + "curator_best_match": cur.get("best_match"), + "curator_5plus_prizes": cur.get("five_plus_prizes"), + "user_avg_match": usr.get("avg_match"), + "user_best_match": usr.get("best_match"), + "user_5plus_prizes": usr.get("five_plus_prizes"), + "user_pattern_summary": json.dumps(user_pattern, ensure_ascii=False) if user_pattern else None, + "draw_pattern_summary": json.dumps(draw_pattern, ensure_ascii=False), + "pattern_delta": delta, + } + rid = db.save_review(payload) + logger.info(f"[grade_weekly_review] saved review id={rid} for draw {drw_no}") + return {"ok": True, "review_id": rid} + + +def run_for_latest() -> dict: + """가장 최근 sync된 추첨 회차로 채점 — cron 진입점.""" + latest = db.get_latest_draw() + if not latest: + return {"ok": False, "reason": "no draws"} + return run_weekly_grading(latest["drw_no"]) diff --git a/lotto/tests/test_grade_weekly_review.py b/lotto/tests/test_grade_weekly_review.py new file mode 100644 index 0000000..b20e665 --- /dev/null +++ b/lotto/tests/test_grade_weekly_review.py @@ -0,0 +1,60 @@ +import sys, os +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +import json +import pytest +from app import db +from app.jobs.grade_weekly_review import run_weekly_grading + + +@pytest.fixture(autouse=True) +def setup_db(tmp_path, monkeypatch): + test_db = tmp_path / "test.db" + monkeypatch.setattr(db, "DB_PATH", str(test_db)) + db.init_db() + yield + + +def _seed_draw(drw_no=1153): + db.upsert_draw({ + "drw_no": drw_no, "drw_date": "2026-05-09", + "n1": 3, "n2": 11, "n3": 17, "n4": 25, "n5": 33, "n6": 41, "bonus": 8, + }) + + +def _seed_briefing(drw_no=1153): + picks = { + "core": [ + {"numbers": [3, 11, 17, 25, 33, 41], "risk_tag": "안정", "reason": "x"}, # 6 + {"numbers": [1, 2, 3, 4, 5, 6], "risk_tag": "안정", "reason": "x"}, # 1 + {"numbers": [3, 11, 17, 4, 5, 6], "risk_tag": "균형", "reason": "x"}, # 3 + {"numbers": [11, 25, 33, 7, 8, 9], "risk_tag": "균형", "reason": "x"}, # 3 + {"numbers": [3, 11, 17, 25, 33, 9], "risk_tag": "공격", "reason": "x"}, # 5 + ], + "bonus": [], "extended": [], "pool": [], + } + db.save_briefing({ + "draw_no": drw_no, "picks": picks, + "narrative": {"headline": "h", "summary_3lines": ["a", "b", "c"], "retrospective": ""}, + "confidence": 70, "model": "test", + }) + + +def test_grade_with_curator_only_no_purchase(): + _seed_draw() + _seed_briefing() + run_weekly_grading(1153) + rev = db.get_review(1153) + assert rev is not None + assert rev["curator_avg_match"] == round((6+1+3+3+5)/5, 2) + assert rev["curator_best_match"] == 6 + assert rev["curator_5plus_prizes"] == 4 # 6,3,3,5 ≥3 (네 개) + assert rev["user_avg_match"] is None # 구매 없음 + + +def test_grade_with_no_briefing(): + _seed_draw() + run_weekly_grading(1153) + rev = db.get_review(1153) + assert rev is not None + assert rev["curator_avg_match"] is None