feat: smart recommendation generator with feedback loop and result checker
This commit is contained in:
66
backend/app/checker.py
Normal file
66
backend/app/checker.py
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import json
|
||||||
|
from .db import (
|
||||||
|
_conn, get_draw, update_recommendation_result
|
||||||
|
)
|
||||||
|
|
||||||
|
def _calc_rank(my_nums: list[int], win_nums: list[int], bonus: int) -> tuple[int, int, bool]:
|
||||||
|
"""
|
||||||
|
(rank, correct_cnt, has_bonus) 반환
|
||||||
|
rank: 1~5 (1등~5등), 0 (낙첨)
|
||||||
|
"""
|
||||||
|
matched = set(my_nums) & set(win_nums)
|
||||||
|
cnt = len(matched)
|
||||||
|
has_bonus = bonus in my_nums
|
||||||
|
|
||||||
|
if cnt == 6:
|
||||||
|
return 1, cnt, has_bonus
|
||||||
|
if cnt == 5 and has_bonus:
|
||||||
|
return 2, cnt, has_bonus
|
||||||
|
if cnt == 5:
|
||||||
|
return 3, cnt, has_bonus
|
||||||
|
if cnt == 4:
|
||||||
|
return 4, cnt, has_bonus
|
||||||
|
if cnt == 3:
|
||||||
|
return 5, cnt, has_bonus
|
||||||
|
|
||||||
|
return 0, cnt, has_bonus
|
||||||
|
|
||||||
|
def check_results_for_draw(drw_no: int) -> int:
|
||||||
|
"""
|
||||||
|
특정 회차(drw_no) 결과가 나왔을 때,
|
||||||
|
해당 회차를 타겟으로 했던(based_on_draw == drw_no - 1) 추천들을 채점한다.
|
||||||
|
반환값: 채점한 개수
|
||||||
|
"""
|
||||||
|
win_row = get_draw(drw_no)
|
||||||
|
if not win_row:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
win_nums = [
|
||||||
|
win_row["n1"], win_row["n2"], win_row["n3"],
|
||||||
|
win_row["n4"], win_row["n5"], win_row["n6"]
|
||||||
|
]
|
||||||
|
bonus = win_row["bonus"]
|
||||||
|
|
||||||
|
# based_on_draw가 (이번회차 - 1)인 것들 조회
|
||||||
|
# 즉, 1000회차 추첨 결과로는, 999회차 데이터를 바탕으로 1000회차를 예측한 것들을 채점
|
||||||
|
target_based_on = drw_no - 1
|
||||||
|
|
||||||
|
with _conn() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, numbers
|
||||||
|
FROM recommendations
|
||||||
|
WHERE based_on_draw = ? AND checked = 0
|
||||||
|
""",
|
||||||
|
(target_based_on,)
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
count = 0
|
||||||
|
for r in rows:
|
||||||
|
my_nums = json.loads(r["numbers"])
|
||||||
|
rank, correct, has_bonus = _calc_rank(my_nums, win_nums, bonus)
|
||||||
|
|
||||||
|
update_recommendation_result(r["id"], rank, correct, has_bonus)
|
||||||
|
count += 1
|
||||||
|
|
||||||
|
return count
|
||||||
@@ -63,6 +63,17 @@ def init_db() -> None:
|
|||||||
_ensure_column(conn, "recommendations", "tags",
|
_ensure_column(conn, "recommendations", "tags",
|
||||||
"ALTER TABLE recommendations ADD COLUMN tags TEXT NOT NULL DEFAULT '[]';")
|
"ALTER TABLE recommendations ADD COLUMN tags TEXT NOT NULL DEFAULT '[]';")
|
||||||
|
|
||||||
|
# ✅ 결과 채점용 컬럼 추가
|
||||||
|
_ensure_column(conn, "recommendations", "rank",
|
||||||
|
"ALTER TABLE recommendations ADD COLUMN rank INTEGER;")
|
||||||
|
_ensure_column(conn, "recommendations", "correct_count",
|
||||||
|
"ALTER TABLE recommendations ADD COLUMN correct_count INTEGER DEFAULT 0;")
|
||||||
|
_ensure_column(conn, "recommendations", "has_bonus",
|
||||||
|
"ALTER TABLE recommendations ADD COLUMN has_bonus INTEGER DEFAULT 0;")
|
||||||
|
_ensure_column(conn, "recommendations", "checked",
|
||||||
|
"ALTER TABLE recommendations ADD COLUMN checked INTEGER DEFAULT 0;")
|
||||||
|
|
||||||
|
|
||||||
# ✅ UNIQUE 인덱스(중복 저장 방지)
|
# ✅ UNIQUE 인덱스(중복 저장 방지)
|
||||||
conn.execute("CREATE UNIQUE INDEX IF NOT EXISTS uq_reco_dedup ON recommendations(dedup_hash);")
|
conn.execute("CREATE UNIQUE INDEX IF NOT EXISTS uq_reco_dedup ON recommendations(dedup_hash);")
|
||||||
|
|
||||||
@@ -261,3 +272,15 @@ def delete_recommendation(rec_id: int) -> bool:
|
|||||||
cur = conn.execute("DELETE FROM recommendations WHERE id = ?", (rec_id,))
|
cur = conn.execute("DELETE FROM recommendations WHERE id = ?", (rec_id,))
|
||||||
return cur.rowcount > 0
|
return cur.rowcount > 0
|
||||||
|
|
||||||
|
def update_recommendation_result(rec_id: int, rank: int, correct_count: int, has_bonus: bool) -> bool:
|
||||||
|
with _conn() as conn:
|
||||||
|
cur = conn.execute(
|
||||||
|
"""
|
||||||
|
UPDATE recommendations
|
||||||
|
SET rank = ?, correct_count = ?, has_bonus = ?, checked = 1
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
(rank, correct_count, 1 if has_bonus else 0, rec_id)
|
||||||
|
)
|
||||||
|
return cur.rowcount > 0
|
||||||
|
|
||||||
|
|||||||
100
backend/app/generator.py
Normal file
100
backend/app/generator.py
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
import random
|
||||||
|
import json
|
||||||
|
from typing import Dict, Any, List, Optional
|
||||||
|
|
||||||
|
from .db import _conn, save_recommendation_dedup, get_latest_draw, get_all_draw_numbers
|
||||||
|
from .recommender import recommend_numbers
|
||||||
|
from .main import calc_metrics, calc_recent_overlap # main에 있는 헬퍼 재사용(순환참조 주의 필요 -> 사실 헬퍼는 utils로 빼는게 좋으나 일단 진행)
|
||||||
|
|
||||||
|
# 순환 참조 방지를 위해 main.py의 calc_metrics 등을 utils.py가 아닌 여기서 재정의하거나
|
||||||
|
# main.py에서 generator를 import할 때 함수 내부에서 하도록 처리.
|
||||||
|
# 여기서는 코드가 중복되더라도 안전하게 독립적으로 구현하거나, db/collector만 import.
|
||||||
|
|
||||||
|
def _get_top_performing_params(limit: int = 20) -> List[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
최근 1~5등에 당첨된 추천들의 파라미터 조회
|
||||||
|
"""
|
||||||
|
sql = """
|
||||||
|
SELECT params
|
||||||
|
FROM recommendations
|
||||||
|
WHERE rank > 0 AND rank <= 5
|
||||||
|
ORDER BY id DESC
|
||||||
|
LIMIT ?
|
||||||
|
"""
|
||||||
|
with _conn() as conn:
|
||||||
|
rows = conn.execute(sql, (limit,)).fetchall()
|
||||||
|
|
||||||
|
return [json.loads(r["params"]) for r in rows]
|
||||||
|
|
||||||
|
def _perturb_param(val: float, delta: float, min_val: float, max_val: float, is_int: bool = False) -> float:
|
||||||
|
change = random.uniform(-delta, delta)
|
||||||
|
new_val = val + change
|
||||||
|
new_val = max(min_val, min(new_val, max_val))
|
||||||
|
return int(round(new_val)) if is_int else round(new_val, 2)
|
||||||
|
|
||||||
|
def generate_smart_recommendations(count: int = 10) -> int:
|
||||||
|
"""
|
||||||
|
지능형 자동 생성: 과거 성적 우수 파라미터 기반으로 생성
|
||||||
|
"""
|
||||||
|
draws = get_all_draw_numbers()
|
||||||
|
if not draws:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
latest = get_latest_draw()
|
||||||
|
based_on = latest["drw_no"] if latest else None
|
||||||
|
|
||||||
|
# 1. 성공 사례 조회 (Feedback)
|
||||||
|
top_params = _get_top_performing_params()
|
||||||
|
|
||||||
|
generated_count = 0
|
||||||
|
|
||||||
|
for _ in range(count):
|
||||||
|
# 전략 선택: 이력이 있으면 70% 확률로 모방(Exploitation), 30%는 랜덤(Exploration)
|
||||||
|
use_history = (len(top_params) > 0) and (random.random() < 0.7)
|
||||||
|
|
||||||
|
if use_history:
|
||||||
|
# 과거 우수 파라미터 중 하나 선택하여 변형
|
||||||
|
base = random.choice(top_params)
|
||||||
|
|
||||||
|
# 파라미터 변형 (유전 알고리즘과 유사)
|
||||||
|
p_window = _perturb_param(base.get("recent_window", 200), 50, 10, 500, True)
|
||||||
|
p_weight = _perturb_param(base.get("recent_weight", 2.0), 1.0, 0.1, 10.0, False)
|
||||||
|
p_avoid = _perturb_param(base.get("avoid_recent_k", 5), 2, 0, 20, True)
|
||||||
|
|
||||||
|
# Constraints 로직은 복잡하니 일단 랜덤성 부여하거나 유지
|
||||||
|
# (여기서는 기본 파라미터 위주로 튜닝)
|
||||||
|
|
||||||
|
params = {
|
||||||
|
"recent_window": p_window,
|
||||||
|
"recent_weight": p_weight,
|
||||||
|
"avoid_recent_k": p_avoid,
|
||||||
|
"strategy": "smart_feedback"
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
# 완전 랜덤 탐색
|
||||||
|
params = {
|
||||||
|
"recent_window": random.randint(50, 400),
|
||||||
|
"recent_weight": round(random.uniform(0.5, 5.0), 2),
|
||||||
|
"avoid_recent_k": random.randint(0, 10),
|
||||||
|
"strategy": "random_exploration"
|
||||||
|
}
|
||||||
|
|
||||||
|
# 생성 시도
|
||||||
|
try:
|
||||||
|
# recommend_numbers는 db.py/main.py 로직과 독립적이므로 여기서 사용 가능
|
||||||
|
# 단, recommend_numbers 함수가 어디 있는지 확인 (recommender.py)
|
||||||
|
res = recommend_numbers(
|
||||||
|
draws,
|
||||||
|
recent_window=params["recent_window"],
|
||||||
|
recent_weight=params["recent_weight"],
|
||||||
|
avoid_recent_k=params["avoid_recent_k"]
|
||||||
|
)
|
||||||
|
|
||||||
|
save_recommendation_dedup(based_on, res["numbers"], params)
|
||||||
|
generated_count += 1
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Gen Error: {e}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
return generated_count
|
||||||
@@ -10,8 +10,9 @@ from .db import (
|
|||||||
update_recommendation,
|
update_recommendation,
|
||||||
)
|
)
|
||||||
from .recommender import recommend_numbers
|
from .recommender import recommend_numbers
|
||||||
from .recommender import recommend_numbers
|
|
||||||
from .collector import sync_latest, sync_ensure_all
|
from .collector import sync_latest, sync_ensure_all
|
||||||
|
from .generator import generate_smart_recommendations
|
||||||
|
from .checker import check_results_for_draw
|
||||||
|
|
||||||
app = FastAPI()
|
app = FastAPI()
|
||||||
scheduler = BackgroundScheduler(timezone=os.getenv("TZ", "Asia/Seoul"))
|
scheduler = BackgroundScheduler(timezone=os.getenv("TZ", "Asia/Seoul"))
|
||||||
@@ -80,7 +81,20 @@ def calc_recent_overlap(numbers: List[int], draws: List[Tuple[int, List[int]]],
|
|||||||
@app.on_event("startup")
|
@app.on_event("startup")
|
||||||
def on_startup():
|
def on_startup():
|
||||||
init_db()
|
init_db()
|
||||||
scheduler.add_job(lambda: sync_latest(LATEST_URL), "cron", hour="9,21", minute=10)
|
|
||||||
|
# 1. 로또 당첨번호 동기화 (매일 9시, 21시 10분)
|
||||||
|
# 동기화 후 새로운 회차가 있으면 채점(check)까지 수행
|
||||||
|
def _sync_and_check():
|
||||||
|
res = sync_latest(LATEST_URL)
|
||||||
|
if res["was_new"]:
|
||||||
|
# 새로운 회차(예: 1000회)가 나오면, 999회차 기반 추천들을 채점
|
||||||
|
check_results_for_draw(res["drawNo"])
|
||||||
|
|
||||||
|
scheduler.add_job(_sync_and_check, "cron", hour="9,21", minute=10)
|
||||||
|
|
||||||
|
# 2. 매일 아침 8시: 지능형 자동 추천 (10개씩)
|
||||||
|
scheduler.add_job(lambda: generate_smart_recommendations(10), "cron", hour="8", minute=0)
|
||||||
|
|
||||||
scheduler.start()
|
scheduler.start()
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
@@ -115,7 +129,17 @@ def api_draw(drw_no: int):
|
|||||||
|
|
||||||
@app.post("/api/admin/sync_latest")
|
@app.post("/api/admin/sync_latest")
|
||||||
def admin_sync_latest():
|
def admin_sync_latest():
|
||||||
return sync_latest(LATEST_URL)
|
res = sync_latest(LATEST_URL)
|
||||||
|
# 수동 동기화 시에도 신규 회차면 채점
|
||||||
|
if res["was_new"]:
|
||||||
|
check_results_for_draw(res["drawNo"])
|
||||||
|
return res
|
||||||
|
|
||||||
|
@app.post("/api/admin/auto_gen")
|
||||||
|
def admin_auto_gen(count: int = 10):
|
||||||
|
"""지능형 자동 생성 수동 트리거"""
|
||||||
|
n = generate_smart_recommendations(count)
|
||||||
|
return {"generated": n}
|
||||||
|
|
||||||
@app.get("/api/lotto/stats")
|
@app.get("/api/lotto/stats")
|
||||||
def api_stats():
|
def api_stats():
|
||||||
|
|||||||
Reference in New Issue
Block a user