AI 예측 입력 통일·데이터 전면 보강

- 3모델 페르소나 분화 제거 — 동일 프롬프트+동일 데이터 (차이는 모델 판단만)
- 순위 라인에 게임차·팀 타율·팀 ERA 추가
- 전날 결과+활약(홈런·맹타), 확정 라인업+타자 시즌 타율 주입
  (응원가 파이프라인 헬퍼 재사용, 라인업 미발표 시 생략)
- 선발 상대 ERA 0.00(기록 없음) 오표기 생략

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
jwkim 2026-08-27 10:08:40 +09:00
parent 9e8993c67a
commit f8c24d1f3c
2 changed files with 67 additions and 52 deletions

View File

@ -34,46 +34,21 @@ class MatchContext:
league: str = "wc" # wc(축구) | kbo | mlb | mls — 프롬프트·스코어 범위 분기 league: str = "wc" # wc(축구) | kbo | mlb | mls — 프롬프트·스코어 범위 분기
# 모델별 분석 관점(페르소나) — 동일 경기라도 서로 다른 시각으로 보게 해 # 공통 페르소나 — 세 모델 모두 동일한 입력(동일 프롬프트+동일 데이터)을 받는다.
# 예측이 자연스럽게 갈리도록 한다(강제 분산이 아니라 진짜 판단의 다양화). # 예측 차이는 모델 자체의 판단 차이에서만 나온다.
PERSONA = { ANALYST = (
"GPT": ( "You are an expert soccer analyst. Use ALL of the factual match data provided "
"You are a DATA-DRIVEN analyst. Base your call on recent form, head-to-head, " "below — recent form, standings, head-to-head and matchup context — and weigh "
"FIFA ranking gaps and goals-scored/conceded trends. Be objective and " "it over your prior knowledge to make the most accurate prediction possible."
"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."
),
}
BASEBALL_ANALYST = (
# 야구용 페르소나 — 축구 페르소나와 같은 3분화(데이터/수비·투수/공격) 구도. "You are an expert baseball analyst. Use ALL of the factual match data provided "
BASEBALL_PERSONA = { "below — recent form, standings with games-behind, team batting/ERA, "
"GPT": ( "head-to-head record, starting pitchers, confirmed lineups and yesterday's "
"You are a DATA-DRIVEN baseball analyst. Base your call on recent form, " "results — and weigh it over your prior knowledge to make the most accurate "
"season standings, head-to-head record and run-scored/allowed trends. " "prediction possible."
"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 = { _LEAGUE_LABEL = {
"kbo": "2026 KBO League (Korean professional baseball) regular-season game", "kbo": "2026 KBO League (Korean professional baseball) regular-season game",
@ -82,7 +57,7 @@ _LEAGUE_LABEL = {
def _prompt_baseball(ctx: MatchContext, model: str) -> str: def _prompt_baseball(ctx: MatchContext, model: str) -> str:
persona = BASEBALL_PERSONA[model] persona = BASEBALL_ANALYST # 모델 공통 — model 파라미터는 호출부 호환용
data = f"\n{ctx.data_block}\n" if ctx.data_block else "" data = f"\n{ctx.data_block}\n" if ctx.data_block else ""
draw_note = ( draw_note = (
"KBO regular-season games can end in a DRAW after 12 innings, but draws " "KBO regular-season games can end in a DRAW after 12 innings, but draws "
@ -92,9 +67,7 @@ def _prompt_baseball(ctx: MatchContext, model: str) -> str:
) )
return ( return (
f"{persona}\n" f"{persona}\n"
f"Predict the result of this {_LEAGUE_LABEL[ctx.league]} using YOUR " f"Predict the result of this {_LEAGUE_LABEL[ctx.league]}.\n"
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"Team A (away): {ctx.team_a}\nTeam B (home): {ctx.team_b}\n"
f"Ballpark: {ctx.venue}\nFirst pitch: {ctx.kickoff}\n" f"Ballpark: {ctx.venue}\nFirst pitch: {ctx.kickoff}\n"
f"{data}" f"{data}"
@ -115,9 +88,7 @@ def _prompt(ctx: MatchContext, persona: str) -> str:
data = f"\n{ctx.data_block}\n" if ctx.data_block else "" data = f"\n{ctx.data_block}\n" if ctx.data_block else ""
return ( return (
f"{persona}\n" f"{persona}\n"
f"Predict the result of this 2026 FIFA World Cup match using YOUR perspective " f"Predict the result of this 2026 FIFA World Cup match.\n"
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"Team A: {ctx.team_a}\nTeam B: {ctx.team_b}\n"
f"Venue: {ctx.venue}\nKickoff: {ctx.kickoff}\n" f"Venue: {ctx.venue}\nKickoff: {ctx.kickoff}\n"
f"{data}" f"{data}"
@ -142,8 +113,7 @@ def _prompt_mls(ctx: MatchContext, persona: str) -> str:
return ( return (
f"{persona}\n" f"{persona}\n"
f"Predict the result of this 2026 MLS (Major League Soccer) regular-season " f"Predict the result of this 2026 MLS (Major League Soccer) regular-season "
f"match using YOUR perspective above. Judge independently — it is fine to " f"match.\n"
f"differ from the obvious consensus pick when your perspective warrants it.\n"
f"Team A (away): {ctx.team_a}\nTeam B (home): {ctx.team_b}\n" f"Team A (away): {ctx.team_a}\nTeam B (home): {ctx.team_b}\n"
f"Venue: {ctx.venue}\nKickoff: {ctx.kickoff}\n" f"Venue: {ctx.venue}\nKickoff: {ctx.kickoff}\n"
f"{data}" f"{data}"
@ -166,8 +136,8 @@ def _build_prompt(ctx: MatchContext, model: str) -> str:
if ctx.league in ("kbo", "mlb"): if ctx.league in ("kbo", "mlb"):
return _prompt_baseball(ctx, model) return _prompt_baseball(ctx, model)
if ctx.league == "mls": if ctx.league == "mls":
return _prompt_mls(ctx, PERSONA[model]) return _prompt_mls(ctx, ANALYST)
return _prompt(ctx, PERSONA[model]) return _prompt(ctx, ANALYST)
# JSON Schema (구조화 출력용 — Anthropic/OpenAI 공통) # JSON Schema (구조화 출력용 — Anthropic/OpenAI 공통)

View File

@ -89,17 +89,26 @@ def _starter_line(side: str, s: dict | None) -> str | None:
f" ({s['w']}W-{s['l']}L)" f" ({s['w']}W-{s['l']}L)"
if s.get("w") is not None and s.get("l") is not None else "" if s.get("w") is not None and s.get("l") is not None else ""
) )
vs = f", ERA vs this opponent {s['vsEra']}" if s.get("vsEra") else "" # 상대 ERA 0.00 은 대부분 '대전 기록 없음' — 무실점으로 오해하지 않게 생략
vs_val = s.get("vsEra")
vs = (
f", ERA vs this opponent {vs_val}"
if vs_val not in (None, "", 0, "0", "0.00", "-")
else ""
)
return f"[{side} starting pitcher] {s['name']}{era}{rec}{vs}" return f"[{side} starting pitcher] {s['name']}{era}{rec}{vs}"
def _standing_line(name: str, st: dict | None) -> str | None: def _standing_line(name: str, st: dict | None) -> str | None:
if not st: if not st:
return None return None
gb = f", GB {st['gb']}" if st.get("gb") not in (None, "", 0, "0.0") else ""
ba = f", team AVG {st['avg']}" if st.get("avg") else ""
era = f", team ERA {st['era']}" if st.get("era") else ""
extra = f", last5 {st['last5']}" if st.get("last5") else "" extra = f", last5 {st['last5']}" if st.get("last5") else ""
return ( return (
f"[{name} standings] rank {st.get('rank')}, {st.get('w')}W-" f"[{name} standings] rank {st.get('rank')}, {st.get('w')}W-"
f"{st.get('l')}L (pct {st.get('wra')}){extra}" f"{st.get('l')}L (pct {st.get('wra')}){gb}{ba}{era}{extra}"
) )
@ -132,6 +141,40 @@ async def _cached_extras(db, match: Match) -> list[str]:
return lines return lines
async def _context_extras(db, match: Match) -> list[str]:
"""전날 결과·활약 + 확정 라인업(타자 시즌 타율) — 응원가 파이프라인 헬퍼 재사용.
라인업은 발표 전이면 생략(예측이 킥오프 22h 전부터 생성되므로 보통 미포함).
"""
from .songs import _lineup_line, _yesterday_info, fetch_lineups
lines: list[str] = []
avg_maps: dict[str, dict] = {}
for side, code, name in (
("a", match.team_a_code, match.team_a_short),
("b", match.team_b_code, match.team_b_short),
):
label = "Away" if side == "a" else "Home"
try:
y, avg_map = await _yesterday_info(db, match, code, name)
except Exception: # noqa: BLE001 — 부가 데이터 실패는 생략
y, avg_map = None, {}
avg_maps[side] = avg_map
if y:
lines.append(f"[{label} yesterday] {y}")
try:
lu = await fetch_lineups(match)
except Exception: # noqa: BLE001
lu = None
if lu and lu.get("announced"):
for side in ("a", "b"):
label = "Away" if side == "a" else "Home"
line = _lineup_line(lu, side, avg_maps.get(side))
if line:
lines.append(f"[{label}] {line}")
return lines
async def build_baseball_data_block(db, match: Match) -> str | None: async def build_baseball_data_block(db, match: Match) -> str | None:
ga = await _team_games(db, match.league, match.team_a_code, match.kickoff_at) ga = await _team_games(db, match.league, match.team_a_code, match.kickoff_at)
gb = await _team_games(db, match.league, match.team_b_code, match.kickoff_at) gb = await _team_games(db, match.league, match.team_b_code, match.kickoff_at)
@ -141,11 +184,13 @@ async def build_baseball_data_block(db, match: Match) -> str | None:
h2h = await _h2h_line( h2h = await _h2h_line(
db, match.league, match.team_a_code, match.team_b_code, match.kickoff_at db, match.league, match.team_a_code, match.team_b_code, match.kickoff_at
) )
context = await _context_extras(db, match)
return "\n".join([ return "\n".join([
"=== MATCH DATA (factual; weigh heavily over priors) ===", "=== MATCH DATA (factual; weigh heavily over priors) ===",
"Away " + _team_lines(match.team_a_name, ga), "Away " + _team_lines(match.team_a_name, ga),
"Home " + _team_lines(match.team_b_name, gb), "Home " + _team_lines(match.team_b_name, gb),
f"[Head-to-head in our data] {h2h}", f"[Head-to-head in our data] {h2h}",
*extras, *extras,
*context,
"===", "===",
]) ])