"""백그라운드 워커 — APScheduler (별도 컨테이너). 조사된 '필요한 시간들'을 모두 자동 처리하는 단일 프로세스: 1) 상태 전이 (status_tick_seconds 주기, 기본 60초) scheduled → open(킥오프 D-2) → locked(킥오프 정각) — now 기준 자동 갱신. 2) AI 예측 생성 (매일 KST ai_generate_hour:ai_generate_minute, 기본 00:05) 남은(미종료) 경기에 대해 GPT/Claude/Gemini 실 API 호출 → ai_predictions 갱신. 모델별 독립 처리(키 없거나 실패해도 나머지 모델은 진행). 3) 결과 메일 발송 (status_tick 와 함께 점검) 경기 종료(finished_at) 후 result_email_delay_minutes(기본 180=3시간) 경과 시, 구독자(notify=True+email)에게 개인화 결과 메일 발송 → notified 표시. 실행: python -m app.worker """ from __future__ import annotations import asyncio import logging from datetime import timedelta from apscheduler.schedulers.asyncio import AsyncIOScheduler from sqlalchemy import select from sqlalchemy.orm import selectinload from .config import settings from .database import SessionLocal, init_db from .domain import compute_phase, ensure_aware, now_utc from .models import AIPrediction, Match, UserPrediction from .scoring import load_scoring_data from .services.ai import MatchContext, PROVIDERS, ProviderUnavailable from .services.email import EmailUnavailable, build_result_email, send_email from .services.schedule_fetch import fetch_schedule from .services.schedule_sync import sync_schedule logging.basicConfig(level=logging.INFO) log = logging.getLogger("triplepick.worker") # ── 0) 경기 일정 동기화 (외부 크롤링) ────────────────────── async def sync_schedule_job() -> None: records = await fetch_schedule() if not records: return async with SessionLocal() as db: result = await sync_schedule(db, records) # 일정 변경 후 즉시 상태/투표시간 재평가 await tick_status() # 새 경기가 삽입됐으면 그 경기 AI 예측을 바로 생성(다음 주기까지 비어있지 않게) if result.get("inserted"): log.info("schedule: 신규 %d경기 → AI 예측 즉시 생성", result["inserted"]) await generate_ai_predictions() # ── 1) 상태 전이 ──────────────────────────────────────────── async def tick_status() -> None: async with SessionLocal() as db: now = now_utc() matches = (await db.execute(select(Match))).scalars().all() changed = 0 for m in matches: # 화면용 phase 와 동일 기준으로 status 저장(단일 기준): # scheduled → open → locked(투표종료) → live(경기중) → finished target = compute_phase(m, now) if m.status != target: m.status = target changed += 1 if changed: await db.commit() log.info("status_tick: %d matches updated", changed) await maybe_send_result_emails() # ── 2) AI 예측 생성 (실연동) ──────────────────────────────── async def generate_ai_predictions() -> None: async with SessionLocal() as db: matches = ( await db.execute( select(Match) .where(Match.result_outcome.is_(None)) .options(selectinload(Match.predictions)) ) ).scalars().all() for m in matches: ctx = MatchContext( team_a=m.team_a_name, team_b=m.team_b_name, venue=m.venue, kickoff=m.kickoff_at.isoformat(), ) for model, fn in PROVIDERS.items(): try: data = await fn(ctx) except ProviderUnavailable as e: log.warning("AI %s skipped (%s)", model, e) continue except Exception as e: # noqa: BLE001 log.error("AI %s failed for %s: %s", model, m.match_id, e) continue pred = next((p for p in m.predictions if p.model == model), None) if pred is None: pred = AIPrediction(match_id=m.match_id, model=model) db.add(pred) m.predictions.append(pred) pred.outcome = data["outcome"] pred.score_a = data["scoreA"] pred.score_b = data["scoreB"] pred.confidence_pct = data["confidencePct"] pred.reason_ko = data["reasonKo"] pred.reason_en = data["reasonEn"] pred.generated_at = now_utc() pred.source = "llm" log.info("AI %s → %s %d-%d", model, m.match_id, data["scoreA"], data["scoreB"]) await db.commit() # ── 3) 결과 메일 ──────────────────────────────────────────── async def maybe_send_result_emails() -> None: async with SessionLocal() as db: cutoff = now_utc() - timedelta(minutes=settings.result_email_delay_minutes) matches = ( await db.execute( select(Match) .where( Match.result_outcome.is_not(None), Match.finished_at.is_not(None), Match.results_emailed_at.is_(None), ) .options(selectinload(Match.predictions)) ) ).scalars().all() for m in matches: if m.finished_at and ensure_aware(m.finished_at) > cutoff: continue # 아직 지연시간(3시간) 미경과 picks = ( await db.execute( select(UserPrediction).where( UserPrediction.match_id == m.match_id, UserPrediction.notify.is_(True), UserPrediction.email.is_not(None), UserPrediction.notified.is_(False), ) ) ).scalars().all() ai_lines = [(p.model, p.outcome == m.result_outcome) for p in m.predictions] match_url = f"{settings.public_origin}/match/{m.match_id}" sent_any = False for pk in picks: subject, html, text = build_result_email( team_a=m.team_a_short, team_b=m.team_b_short, result_a=m.result_score_a or 0, result_b=m.result_score_b or 0, my_a=pk.score_a, my_b=pk.score_b, my_points=pk.points or 0, ai_lines=ai_lines, match_url=match_url, ) try: await send_email(pk.email, subject, html, text) # type: ignore[arg-type] pk.notified = True sent_any = True except EmailUnavailable as e: log.warning("result email skipped (%s)", e) break # SMTP 미설정 — 이 경기는 다음 틱에 재시도 except Exception as e: # noqa: BLE001 log.error("result email failed → %s: %s", pk.email, e) # 모든 구독자에게 시도 완료(또는 구독자 없음)면 발송완료 표시 still_pending = any( (not pk.notified) for pk in picks ) if not still_pending: m.results_emailed_at = now_utc() await db.commit() def build_scheduler() -> AsyncIOScheduler: sched = AsyncIOScheduler(timezone=settings.timezone) # 경기 일정: 매일 KST 09:00 외부 크롤링 → 경기·투표시간 갱신 sched.add_job( sync_schedule_job, "cron", hour=settings.schedule_sync_hour, minute=settings.schedule_sync_minute, id="schedule_sync", ) # 투표시간 상태 전이 (open/locked) sched.add_job( tick_status, "interval", seconds=settings.status_tick_seconds, id="status_tick", next_run_time=now_utc(), ) # AI 예측 생성: 매일 KST 00:05 (실 API) sched.add_job( generate_ai_predictions, "cron", hour=settings.ai_generate_hour, minute=settings.ai_generate_minute, id="ai_generate", ) return sched async def main() -> None: load_scoring_data() # data/scoring.json → 배점·배제 대상 (채점·결과메일에 사용) await init_db() # 테이블 보장 (idempotent). 시드는 API 가 담당. # 기동 시 1회: 일정 동기화 → AI 예측 생성(키 있을 때 GPT·Gemini 즉시 채움) await sync_schedule_job() await generate_ai_predictions() sched = build_scheduler() sched.start() log.info( "scheduling-server started — schedule sync daily %02d:%02d · status every %ss · " "AI gen daily %02d:%02d %s · result email +%dmin", settings.schedule_sync_hour, settings.schedule_sync_minute, settings.status_tick_seconds, settings.ai_generate_hour, settings.ai_generate_minute, settings.timezone, settings.result_email_delay_minutes, ) # 영구 대기 stop = asyncio.Event() try: await stop.wait() finally: sched.shutdown() if __name__ == "__main__": asyncio.run(main())