From f8c24d1f3c073bce157289d974b5ba5cbc351e8f Mon Sep 17 00:00:00 2001 From: jwkim Date: Thu, 27 Aug 2026 10:08:40 +0900 Subject: [PATCH] =?UTF-8?q?AI=20=EC=98=88=EC=B8=A1=20=EC=9E=85=EB=A0=A5=20?= =?UTF-8?q?=ED=86=B5=EC=9D=BC=C2=B7=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EC=A0=84?= =?UTF-8?q?=EB=A9=B4=20=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 3모델 페르소나 분화 제거 — 동일 프롬프트+동일 데이터 (차이는 모델 판단만) - 순위 라인에 게임차·팀 타율·팀 ERA 추가 - 전날 결과+활약(홈런·맹타), 확정 라인업+타자 시즌 타율 주입 (응원가 파이프라인 헬퍼 재사용, 라인업 미발표 시 생략) - 선발 상대 ERA 0.00(기록 없음) 오표기 생략 Co-Authored-By: Claude Fable 5 --- backend/app/services/ai.py | 70 ++++++++------------------- backend/app/services/baseball_data.py | 49 ++++++++++++++++++- 2 files changed, 67 insertions(+), 52 deletions(-) diff --git a/backend/app/services/ai.py b/backend/app/services/ai.py index f73c817..fe931b8 100644 --- a/backend/app/services/ai.py +++ b/backend/app/services/ai.py @@ -34,46 +34,21 @@ class MatchContext: league: str = "wc" # wc(축구) | kbo | mlb | mls — 프롬프트·스코어 범위 분기 -# 모델별 분석 관점(페르소나) — 동일 경기라도 서로 다른 시각으로 보게 해 -# 예측이 자연스럽게 갈리도록 한다(강제 분산이 아니라 진짜 판단의 다양화). -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." - ), -} +# 공통 페르소나 — 세 모델 모두 동일한 입력(동일 프롬프트+동일 데이터)을 받는다. +# 예측 차이는 모델 자체의 판단 차이에서만 나온다. +ANALYST = ( + "You are an expert soccer analyst. Use ALL of the factual match data provided " + "below — recent form, standings, head-to-head and matchup context — and weigh " + "it over your prior knowledge to make the most accurate prediction possible." +) - -# 야구용 페르소나 — 축구 페르소나와 같은 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." - ), -} +BASEBALL_ANALYST = ( + "You are an expert baseball analyst. Use ALL of the factual match data provided " + "below — recent form, standings with games-behind, team batting/ERA, " + "head-to-head record, starting pitchers, confirmed lineups and yesterday's " + "results — and weigh it over your prior knowledge to make the most accurate " + "prediction possible." +) _LEAGUE_LABEL = { "kbo": "2026 KBO League (Korean professional baseball) regular-season game", @@ -82,7 +57,7 @@ _LEAGUE_LABEL = { 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 "" draw_note = ( "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 ( 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"Predict the result of this {_LEAGUE_LABEL[ctx.league]}.\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}" @@ -115,9 +88,7 @@ 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"Predict the result of this 2026 FIFA World Cup match.\n" f"Team A: {ctx.team_a}\nTeam B: {ctx.team_b}\n" f"Venue: {ctx.venue}\nKickoff: {ctx.kickoff}\n" f"{data}" @@ -142,8 +113,7 @@ def _prompt_mls(ctx: MatchContext, persona: str) -> str: return ( f"{persona}\n" 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"differ from the obvious consensus pick when your perspective warrants it.\n" + f"match.\n" f"Team A (away): {ctx.team_a}\nTeam B (home): {ctx.team_b}\n" f"Venue: {ctx.venue}\nKickoff: {ctx.kickoff}\n" f"{data}" @@ -166,8 +136,8 @@ def _build_prompt(ctx: MatchContext, model: str) -> str: if ctx.league in ("kbo", "mlb"): return _prompt_baseball(ctx, model) if ctx.league == "mls": - return _prompt_mls(ctx, PERSONA[model]) - return _prompt(ctx, PERSONA[model]) + return _prompt_mls(ctx, ANALYST) + return _prompt(ctx, ANALYST) # JSON Schema (구조화 출력용 — Anthropic/OpenAI 공통) diff --git a/backend/app/services/baseball_data.py b/backend/app/services/baseball_data.py index db3ed47..8c7efbc 100644 --- a/backend/app/services/baseball_data.py +++ b/backend/app/services/baseball_data.py @@ -89,17 +89,26 @@ def _starter_line(side: str, s: dict | None) -> str | None: f" ({s['w']}W-{s['l']}L)" 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}" def _standing_line(name: str, st: dict | None) -> str | None: if not st: 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 "" return ( 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 +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: 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) @@ -141,11 +184,13 @@ async def build_baseball_data_block(db, match: Match) -> str | None: h2h = await _h2h_line( db, match.league, match.team_a_code, match.team_b_code, match.kickoff_at ) + context = await _context_extras(db, match) return "\n".join([ "=== MATCH DATA (factual; weigh heavily over priors) ===", "Away " + _team_lines(match.team_a_name, ga), "Home " + _team_lines(match.team_b_name, gb), f"[Head-to-head in our data] {h2h}", *extras, + *context, "===", ])