From 432840a38da43ce7a5ad3ecb930778cbb17c2bc3 Mon Sep 17 00:00:00 2001 From: gahusb Date: Mon, 26 Jan 2026 01:15:49 +0900 Subject: [PATCH] feat: smart recommendation generator with feedback loop and result checker --- backend/app/checker.py | 66 ++++++++++++++++++++++++++ backend/app/db.py | 23 +++++++++ backend/app/generator.py | 100 +++++++++++++++++++++++++++++++++++++++ backend/app/main.py | 30 ++++++++++-- 4 files changed, 216 insertions(+), 3 deletions(-) create mode 100644 backend/app/checker.py create mode 100644 backend/app/generator.py diff --git a/backend/app/checker.py b/backend/app/checker.py new file mode 100644 index 0000000..907bc2c --- /dev/null +++ b/backend/app/checker.py @@ -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 diff --git a/backend/app/db.py b/backend/app/db.py index 193105a..ed510d8 100644 --- a/backend/app/db.py +++ b/backend/app/db.py @@ -63,6 +63,17 @@ def init_db() -> None: _ensure_column(conn, "recommendations", "tags", "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 인덱스(중복 저장 방지) 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,)) 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 + diff --git a/backend/app/generator.py b/backend/app/generator.py new file mode 100644 index 0000000..623bccb --- /dev/null +++ b/backend/app/generator.py @@ -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 diff --git a/backend/app/main.py b/backend/app/main.py index 4fb2820..fbd5bb8 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -10,8 +10,9 @@ from .db import ( update_recommendation, ) from .recommender import recommend_numbers -from .recommender import recommend_numbers from .collector import sync_latest, sync_ensure_all +from .generator import generate_smart_recommendations +from .checker import check_results_for_draw app = FastAPI() 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") def on_startup(): 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() @app.get("/health") @@ -115,7 +129,17 @@ def api_draw(drw_no: int): @app.post("/api/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") def api_stats():