o2o-triple-pick/backend/app/services/ai.py

247 lines
10 KiB
Python

"""AI 3모델 실연동 — GPT(OpenAI) · Claude(Anthropic) · Gemini(Google).
각 모델에 동일한 경기 컨텍스트를 주고 구조화된 예측 JSON 을 받는다.
키가 없으면 ProviderUnavailable 을 던지고(조용한 실패 0), 워커는 모델별로
독립 처리하여 가능한 것만 갱신한다.
반환 표준 dict:
{outcome, scoreA, scoreB, confidencePct, reasonKo, reasonEn}
"""
from __future__ import annotations
import json
import logging
from dataclasses import dataclass
from ..config import settings
log = logging.getLogger("triplepick.ai")
OUTCOMES = {"TEAM_A_WIN", "DRAW", "TEAM_B_WIN"}
class ProviderUnavailable(RuntimeError):
"""API 키 미설정 등으로 해당 모델을 호출할 수 없음."""
@dataclass
class MatchContext:
team_a: str # 표시명 (예: Korea Republic / 한화 이글스)
team_b: str
venue: str
kickoff: str # ISO
data_block: str | None = None # 실데이터(폼·H2H·랭킹 등) 주입 블록. 없으면 이름만.
league: str = "wc" # wc(축구) | kbo | mlb — 프롬프트·스코어 범위 분기
# 모델별 분석 관점(페르소나) — 동일 경기라도 서로 다른 시각으로 보게 해
# 예측이 자연스럽게 갈리도록 한다(강제 분산이 아니라 진짜 판단의 다양화).
PERSONA = {
"GPT": (
"You are a DATA-DRIVEN analyst. Base your call on recent form, head-to-head, "
"FIFA ranking gaps and goals-scored/conceded trends. Be objective and "
"evidence-led; pick the scoreline the numbers most support."
),
"Claude": (
"You are a TACTICAL analyst. Focus on matchups, defensive organization, "
"midfield control and game-state. Take low-scoring games, tight margins and "
"genuine upset potential seriously — do not just rubber-stamp the favorite."
),
"Gemini": (
"You are an ATTACKING-MINDED analyst. Weigh momentum, attacking quality, "
"star players and scoring potential. Lean toward open, higher-scoring "
"scenarios when the talent and tempo justify it."
),
}
# 야구용 페르소나 — 축구 페르소나와 같은 3분화(데이터/수비·투수/공격) 구도.
BASEBALL_PERSONA = {
"GPT": (
"You are a DATA-DRIVEN baseball analyst. Base your call on recent form, "
"season standings, head-to-head record and run-scored/allowed trends. "
"Be objective and evidence-led; pick the scoreline the numbers most support."
),
"Claude": (
"You are a PITCHING-AND-DEFENSE analyst. Weigh starting rotation strength, "
"bullpen fatigue, and defensive quality. Take low-scoring games, tight "
"margins and genuine upset potential seriously — do not just rubber-stamp "
"the favorite."
),
"Gemini": (
"You are an OFFENSE-MINDED analyst. Weigh lineup depth, power hitting, "
"momentum and ballpark factors. Lean toward open, higher-scoring scenarios "
"when the bats and conditions justify it."
),
}
_LEAGUE_LABEL = {
"kbo": "2026 KBO League (Korean professional baseball) regular-season game",
"mlb": "2026 MLB (Major League Baseball) regular-season game",
}
def _prompt_baseball(ctx: MatchContext, model: str) -> str:
persona = BASEBALL_PERSONA[model]
data = f"\n{ctx.data_block}\n" if ctx.data_block else ""
draw_note = (
"KBO regular-season games can end in a DRAW after 12 innings, but draws "
"are rare (~1-2% of games); only predict a draw with strong reason.\n"
if ctx.league == "kbo"
else "MLB games cannot end in a draw — never predict DRAW.\n"
)
return (
f"{persona}\n"
f"Predict the result of this {_LEAGUE_LABEL[ctx.league]} using YOUR "
f"perspective above. Judge independently — it is fine to differ from the "
f"obvious consensus pick when your perspective warrants it.\n"
f"Team A (away): {ctx.team_a}\nTeam B (home): {ctx.team_b}\n"
f"Ballpark: {ctx.venue}\nFirst pitch: {ctx.kickoff}\n"
f"{data}"
f"Important: Team B is the HOME team — home advantage applies. {draw_note}\n"
f"Predict the final score in runs. "
f"Respond with a single JSON object and nothing else, with keys:\n"
f' "scoreA": integer 0-25 (Team A runs),\n'
f' "scoreB": integer 0-25 (Team B runs),\n'
f' "outcome": one of "TEAM_A_WIN" | "DRAW" | "TEAM_B_WIN" (must match the score),\n'
f' "confidencePct": integer 0-100,\n'
f' "reasonKo": a short one-line rationale in Korean (max ~30 chars),\n'
f' "reasonEn": a short one-line rationale in English (max ~60 chars).\n'
)
def _prompt(ctx: MatchContext, persona: str) -> str:
# 실데이터 블록이 있으면 팀/킥오프 다음에 삽입(없으면 빈 문자열 — 기존 동작 동일).
data = f"\n{ctx.data_block}\n" if ctx.data_block else ""
return (
f"{persona}\n"
f"Predict the result of this 2026 FIFA World Cup match using YOUR perspective "
f"above. Judge independently — it is fine to differ from the obvious consensus "
f"pick when your perspective warrants it.\n"
f"Team A: {ctx.team_a}\nTeam B: {ctx.team_b}\n"
f"Venue: {ctx.venue}\nKickoff: {ctx.kickoff}\n"
f"{data}"
f"Important: World Cup group-stage matches are played at NEUTRAL venues. "
f"Neither team has home advantage unless it is a host nation "
f"(Mexico, USA, or Canada). Do NOT claim home advantage otherwise.\n\n"
f"Predict the final regulation-time score. "
f"Respond with a single JSON object and nothing else, with keys:\n"
f' "scoreA": integer 0-9 (Team A goals),\n'
f' "scoreB": integer 0-9 (Team B goals),\n'
f' "outcome": one of "TEAM_A_WIN" | "DRAW" | "TEAM_B_WIN" (must match the score),\n'
f' "confidencePct": integer 0-100 (confidence in the predicted winner; '
f"for a draw, confidence in the draw),\n"
f' "reasonKo": a short one-line rationale in Korean (max ~30 chars),\n'
f' "reasonEn": a short one-line rationale in English (max ~60 chars).\n'
)
def _build_prompt(ctx: MatchContext, model: str) -> str:
"""리그별 프롬프트 선택 — 야구(kbo/mlb)는 야구 프롬프트, 그 외 축구."""
if ctx.league in ("kbo", "mlb"):
return _prompt_baseball(ctx, model)
return _prompt(ctx, PERSONA[model])
# JSON Schema (구조화 출력용 — Anthropic/OpenAI 공통)
_SCHEMA = {
"type": "object",
"additionalProperties": False,
"properties": {
"scoreA": {"type": "integer"},
"scoreB": {"type": "integer"},
"outcome": {"type": "string", "enum": ["TEAM_A_WIN", "DRAW", "TEAM_B_WIN"]},
"confidencePct": {"type": "integer"},
"reasonKo": {"type": "string"},
"reasonEn": {"type": "string"},
},
"required": ["scoreA", "scoreB", "outcome", "confidencePct", "reasonKo", "reasonEn"],
}
def _normalize(data: dict, max_score: int = 9) -> dict:
a = max(0, min(max_score, int(data["scoreA"])))
b = max(0, min(max_score, int(data["scoreB"])))
# outcome 은 스코어와 일관되도록 서버에서 재도출 (모델 불일치 방지)
outcome = "TEAM_A_WIN" if a > b else "TEAM_B_WIN" if a < b else "DRAW"
conf = max(0, min(100, int(data.get("confidencePct", 50))))
return {
"outcome": outcome,
"scoreA": a,
"scoreB": b,
"confidencePct": conf,
"reasonKo": str(data.get("reasonKo", "")).strip()[:120],
"reasonEn": str(data.get("reasonEn", "")).strip()[:160],
}
# ── GPT (OpenAI) ────────────────────────────────────────────
async def predict_gpt(ctx: MatchContext) -> dict:
if not settings.openai_api_key:
raise ProviderUnavailable("OPENAI_API_KEY 미설정")
from openai import AsyncOpenAI
client = AsyncOpenAI(api_key=settings.openai_api_key)
resp = await client.chat.completions.create(
model=settings.openai_model,
messages=[
{"role": "system", "content": "You output only valid JSON."},
{"role": "user", "content": _build_prompt(ctx, "GPT")},
],
response_format={"type": "json_object"},
)
content = resp.choices[0].message.content or "{}"
return _normalize(json.loads(content), 25 if ctx.league in ("kbo", "mlb") else 9)
# ── Claude (Anthropic) ──────────────────────────────────────
async def predict_claude(ctx: MatchContext) -> dict:
if not settings.anthropic_api_key:
raise ProviderUnavailable("ANTHROPIC_API_KEY 미설정")
from anthropic import AsyncAnthropic
client = AsyncAnthropic(api_key=settings.anthropic_api_key)
async def _create(**extra): # noqa: ANN003
return await client.messages.create(
model=settings.anthropic_model,
max_tokens=1024,
messages=[{"role": "user", "content": _build_prompt(ctx, "Claude")}],
**extra,
)
# 1순위: 구조화 출력(output_config.format) + Opus 4.8 어댑티브 thinking.
# SDK/모델 버전에 따라 미지원이면 평문 JSON 파싱으로 폴백.
try:
msg = await _create(
thinking={"type": "adaptive"},
output_config={"format": {"type": "json_schema", "schema": _SCHEMA}},
)
except TypeError:
msg = await _create()
text = "".join(b.text for b in msg.content if getattr(b, "type", "") == "text")
return _normalize(json.loads(text), 25 if ctx.league in ("kbo", "mlb") else 9)
# ── Gemini (Google) ─────────────────────────────────────────
async def predict_gemini(ctx: MatchContext) -> dict:
if not settings.google_api_key:
raise ProviderUnavailable("GOOGLE_API_KEY 미설정")
from google import genai
from google.genai import types
client = genai.Client(api_key=settings.google_api_key)
resp = await client.aio.models.generate_content(
model=settings.google_model,
contents=_build_prompt(ctx, "Gemini"),
config=types.GenerateContentConfig(response_mime_type="application/json"),
)
return _normalize(json.loads(resp.text or "{}"), 25 if ctx.league in ("kbo", "mlb") else 9)
PROVIDERS = {
"GPT": predict_gpt,
"Claude": predict_claude,
"Gemini": predict_gemini,
}