import os from typing import Optional, List, Dict, Any, Tuple from fastapi import FastAPI, HTTPException from pydantic import BaseModel from apscheduler.schedulers.background import BackgroundScheduler from .db import ( init_db, get_draw, get_latest_draw, get_all_draw_numbers, save_recommendation_dedup, list_recommendations_ex, delete_recommendation, update_recommendation, ) from .recommender import recommend_numbers from .collector import sync_latest app = FastAPI() scheduler = BackgroundScheduler(timezone=os.getenv("TZ", "Asia/Seoul")) ALL_URL = os.getenv("LOTTO_ALL_URL", "https://smok95.github.io/lotto/results/all.json") LATEST_URL = os.getenv("LOTTO_LATEST_URL", "https://smok95.github.io/lotto/results/latest.json") def calc_metrics(numbers: List[int]) -> Dict[str, Any]: nums = sorted(numbers) s = sum(nums) odd = sum(1 for x in nums if x % 2 == 1) even = len(nums) - odd mn, mx = nums[0], nums[-1] rng = mx - mn # 1-10, 11-20, 21-30, 31-40, 41-45 buckets = { "1-10": 0, "11-20": 0, "21-30": 0, "31-40": 0, "41-45": 0, } for x in nums: if 1 <= x <= 10: buckets["1-10"] += 1 elif 11 <= x <= 20: buckets["11-20"] += 1 elif 21 <= x <= 30: buckets["21-30"] += 1 elif 31 <= x <= 40: buckets["31-40"] += 1 else: buckets["41-45"] += 1 return { "sum": s, "odd": odd, "even": even, "min": mn, "max": mx, "range": rng, "buckets": buckets, } def calc_recent_overlap(numbers: List[int], draws: List[Tuple[int, List[int]]], last_k: int) -> Dict[str, Any]: """ draws: [(drw_no, [n1..n6]), ...] 오름차순 last_k: 최근 k회 기준 중복 """ if last_k <= 0: return {"last_k": 0, "repeats": 0, "repeated_numbers": []} recent = draws[-last_k:] if len(draws) >= last_k else draws recent_set = set() for _, nums in recent: recent_set.update(nums) repeated = sorted(set(numbers) & recent_set) return { "last_k": len(recent), "repeats": len(repeated), "repeated_numbers": repeated, } @app.on_event("startup") def on_startup(): init_db() scheduler.add_job(lambda: sync_latest(LATEST_URL), "cron", hour="9,21", minute=10) scheduler.start() @app.get("/health") def health(): return {"ok": True} @app.get("/api/lotto/latest") def api_latest(): row = get_latest_draw() if not row: raise HTTPException(status_code=404, detail="No data yet") return { "drawNo": row["drw_no"], "date": row["drw_date"], "numbers": [row["n1"], row["n2"], row["n3"], row["n4"], row["n5"], row["n6"]], "bonus": row["bonus"], "metrics": calc_metrics([row["n1"], row["n2"], row["n3"], row["n4"], row["n5"], row["n6"]]), } @app.get("/api/lotto/{drw_no:int}") def api_draw(drw_no: int): row = get_draw(drw_no) if not row: raise HTTPException(status_code=404, detail="Not found") return { "drwNo": row["drw_no"], "date": row["drw_date"], "numbers": [row["n1"], row["n2"], row["n3"], row["n4"], row["n5"], row["n6"]], "bonus": row["bonus"], "metrics": calc_metrics([row["n1"], row["n2"], row["n3"], row["n4"], row["n5"], row["n6"]]), } @app.post("/api/admin/sync_latest") def admin_sync_latest(): return sync_latest(LATEST_URL) # ---------- ✅ recommend (dedup save) ---------- @app.get("/api/lotto/recommend") def api_recommend( recent_window: int = 200, recent_weight: float = 2.0, avoid_recent_k: int = 5, # ---- optional constraints (Lotto Lab) ---- sum_min: Optional[int] = None, sum_max: Optional[int] = None, odd_min: Optional[int] = None, odd_max: Optional[int] = None, range_min: Optional[int] = None, range_max: Optional[int] = None, max_overlap_latest: Optional[int] = None, # 최근 avoid_recent_k 회차와 중복 허용 개수 max_try: int = 200, # 조건 맞는 조합 찾기 재시도 ): draws = get_all_draw_numbers() if not draws: raise HTTPException(status_code=404, detail="No data yet") latest = get_latest_draw() params = { "recent_window": recent_window, "recent_weight": float(recent_weight), "avoid_recent_k": avoid_recent_k, "sum_min": sum_min, "sum_max": sum_max, "odd_min": odd_min, "odd_max": odd_max, "range_min": range_min, "range_max": range_max, "max_overlap_latest": max_overlap_latest, "max_try": int(max_try), } def _accept(nums: List[int]) -> bool: m = calc_metrics(nums) if sum_min is not None and m["sum"] < sum_min: return False if sum_max is not None and m["sum"] > sum_max: return False if odd_min is not None and m["odd"] < odd_min: return False if odd_max is not None and m["odd"] > odd_max: return False if range_min is not None and m["range"] < range_min: return False if range_max is not None and m["range"] > range_max: return False if max_overlap_latest is not None: ov = calc_recent_overlap(nums, draws, last_k=avoid_recent_k) if ov["repeats"] > max_overlap_latest: return False return True chosen = None explain = None tries = 0 while tries < max_try: tries += 1 result = recommend_numbers( draws, recent_window=recent_window, recent_weight=recent_weight, avoid_recent_k=avoid_recent_k, ) nums = result["numbers"] if _accept(nums): chosen = nums explain = result["explain"] break if chosen is None: raise HTTPException( status_code=400, detail=f"Constraints too strict. No valid set found in max_try={max_try}. " f"Try relaxing sum/odd/range/overlap constraints.", ) # ✅ dedup save saved = save_recommendation_dedup( latest["drw_no"] if latest else None, chosen, params, ) metrics = calc_metrics(chosen) overlap = calc_recent_overlap(chosen, draws, last_k=avoid_recent_k) return { "id": saved["id"], "saved": saved["saved"], "deduped": saved["deduped"], "based_on_latest_draw": latest["drw_no"] if latest else None, "numbers": chosen, "explain": explain, "params": params, "metrics": metrics, "recent_overlap": overlap, "tries": tries, } # ---------- ✅ history list (filter/paging) ---------- @app.get("/api/history") def api_history( limit: int = 30, offset: int = 0, favorite: Optional[bool] = None, tag: Optional[str] = None, q: Optional[str] = None, sort: str = "id_desc", ): items = list_recommendations_ex( limit=limit, offset=offset, favorite=favorite, tag=tag, q=q, sort=sort, ) draws = get_all_draw_numbers() out = [] for it in items: nums = it["numbers"] out.append({ **it, "metrics": calc_metrics(nums), "recent_overlap": calc_recent_overlap( nums, draws, last_k=int(it["params"].get("avoid_recent_k", 0) or 0) ), }) return { "items": out, "limit": limit, "offset": offset, "filters": {"favorite": favorite, "tag": tag, "q": q, "sort": sort}, } @app.delete("/api/history/{rec_id:int}") def api_history_delete(rec_id: int): ok = delete_recommendation(rec_id) if not ok: raise HTTPException(status_code=404, detail="Not found") return {"deleted": True, "id": rec_id} # ---------- ✅ history update (favorite/note/tags) ---------- class HistoryUpdate(BaseModel): favorite: Optional[bool] = None note: Optional[str] = None tags: Optional[List[str]] = None @app.patch("/api/history/{rec_id:int}") def api_history_patch(rec_id: int, body: HistoryUpdate): ok = update_recommendation(rec_id, favorite=body.favorite, note=body.note, tags=body.tags) if not ok: raise HTTPException(status_code=404, detail="Not found or no changes") return {"updated": True, "id": rec_id} # ---------- ✅ batch recommend ---------- def _batch_unique(draws, count: int, recent_window: int, recent_weight: float, avoid_recent_k: int, max_try: int = 200): items = [] seen = set() tries = 0 while len(items) < count and tries < max_try: tries += 1 r = recommend_numbers(draws, recent_window=recent_window, recent_weight=recent_weight, avoid_recent_k=avoid_recent_k) key = tuple(sorted(r["numbers"])) if key in seen: continue seen.add(key) items.append(r) return items @app.get("/api/lotto/recommend/batch") def api_recommend_batch( count: int = 5, recent_window: int = 200, recent_weight: float = 2.0, avoid_recent_k: int = 5, ): count = max(1, min(count, 20)) draws = get_all_draw_numbers() if not draws: raise HTTPException(status_code=404, detail="No data yet") latest = get_latest_draw() params = { "recent_window": recent_window, "recent_weight": float(recent_weight), "avoid_recent_k": avoid_recent_k, "count": count, } items = _batch_unique(draws, count, recent_window, float(recent_weight), avoid_recent_k) return { "based_on_latest_draw": latest["drw_no"] if latest else None, "count": count, "items": [{ "numbers": it["numbers"], "explain": it["explain"], "metrics": calc_metrics(it["numbers"]), } for it in items], "params": params, } class BatchSave(BaseModel): items: List[List[int]] params: dict @app.post("/api/lotto/recommend/batch") def api_recommend_batch_save(body: BatchSave): latest = get_latest_draw() based = latest["drw_no"] if latest else None created, deduped = [], [] for nums in body.items: saved = save_recommendation_dedup(based, nums, body.params) (created if saved["saved"] else deduped).append(saved["id"]) return {"saved": True, "created_ids": created, "deduped_ids": deduped} @app.get("/api/version") def version(): import os return {"version": os.getenv("APP_VERSION", "dev")}