feat(lotto): grade_weekly_review 통합 잡 — 큐레이터 자기평가 + 패턴 갭
This commit is contained in:
154
lotto/app/jobs/grade_weekly_review.py
Normal file
154
lotto/app/jobs/grade_weekly_review.py
Normal file
@@ -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"])
|
||||||
60
lotto/tests/test_grade_weekly_review.py
Normal file
60
lotto/tests/test_grade_weekly_review.py
Normal file
@@ -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
|
||||||
Reference in New Issue
Block a user