응원가 생성을 라인업 발표 이후로 게이트

- 네이버 preview fullLineUp 조회 — 양팀 타자 8명 이상이면 발표로 간주,
  확정 라인업(타순·포지션 실명)을 가사 컨텍스트에 주입
- 생성 윈도우(킥오프 150분 전~) 안에서 발표까지 대기, 킥오프 40분 전까지
  미발표면 라인업 없이 폴백 생성
- 라인업 없이 만든 곡은 발표 후 자동 재생성(업그레이드) — 기존 트랙을
  유지한 채 새 곡 완성 시점에 교체, 실패 시 이전 곡 복귀

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
jwkim 2026-08-25 09:59:19 +09:00
parent 390e06113d
commit 0fc7598d88
4 changed files with 122 additions and 22 deletions

View File

@ -180,8 +180,10 @@ class Settings(BaseSettings):
suno_model: str = "V5" suno_model: str = "V5"
# 응원가 생성 전체 스위치 (KBO 만 대상 — 한국어 응원가) # 응원가 생성 전체 스위치 (KBO 만 대상 — 한국어 응원가)
songs_enabled: bool = True songs_enabled: bool = True
# 킥오프 N분 전부터 생성 시작 (선발투수·라인업 확정 이후 시점) # 킥오프 N분 전부터 생성 윈도우 시작 — 윈도우 안에서 라인업 발표를 기다린다
song_generate_minutes_before: int = 150 song_generate_minutes_before: int = 150
# 킥오프 N분 전까지 라인업이 안 뜨면 라인업 없이 폴백 생성 (곡 없는 날 방지)
song_lineup_fallback_minutes_before: int = 40
# 생성 대상 점검·생성 상태 폴링 주기(초) # 생성 대상 점검·생성 상태 폴링 주기(초)
song_tick_seconds: int = 120 song_tick_seconds: int = 120

View File

@ -328,6 +328,8 @@ class Song(Base):
status: Mapped[str] = mapped_column(String, default="generating", index=True) status: Mapped[str] = mapped_column(String, default="generating", index=True)
error: Mapped[str] = mapped_column(String, default="") error: Mapped[str] = mapped_column(String, default="")
attempts: Mapped[int] = mapped_column(Integer, default=0) # 실패 재시도 상한용 attempts: Mapped[int] = mapped_column(Integer, default=0) # 실패 재시도 상한용
# 라인업 발표 후 생성했는지 — False 면 라인업 공개 시 자동 재생성(업그레이드) 대상
with_lineup: Mapped[bool] = mapped_column(Boolean, default=False)
# 완성 트랙 [{title, audioUrl, imageUrl, duration}] — 보통 생성당 2곡 # 완성 트랙 [{title, audioUrl, imageUrl, duration}] — 보통 생성당 2곡
tracks: Mapped[list] = mapped_column(JSON, default=list) tracks: Mapped[list] = mapped_column(JSON, default=list)

View File

@ -32,14 +32,19 @@ def _song_out(s: Song) -> dict:
@router.get("/today") @router.get("/today")
async def today_songs(league: str = "kbo", db: AsyncSession = Depends(get_db)) -> list[dict]: async def today_songs(league: str = "kbo", db: AsyncSession = Depends(get_db)) -> list[dict]:
today = datetime.now(KST).date() today = datetime.now(KST).date()
# 업그레이드(라인업 반영 재생성) 중인 곡도 이전 트랙을 계속 서빙 — 재생 공백 없음
rows = ( rows = (
await db.execute( await db.execute(
select(Song) select(Song)
.where(Song.league == league, Song.date_kst == today, Song.status == "complete") .where(
Song.league == league,
Song.date_kst == today,
Song.status.in_(("complete", "generating")),
)
.order_by(Song.match_id, Song.team_code) .order_by(Song.match_id, Song.team_code)
) )
).scalars().all() ).scalars().all()
return [_song_out(s) for s in rows] return [_song_out(s) for s in rows if s.tracks]
@router.post("/callback") @router.post("/callback")

View File

@ -168,7 +168,60 @@ async def _series_line(db, m: Match) -> str | None:
return f"{len(run)}연전 중 {idx}차전" return f"{len(run)}연전 중 {idx}차전"
async def build_context(db, m: Match, side: str) -> tuple[str, str, str]: async def fetch_lineups(m: Match) -> dict | None:
"""네이버 preview 에서 오늘 선발 라인업 조회 (발표 전이면 announced=False).
fullLineUp 은 발표 전엔 선발투수 1명만 담긴다 — 양팀 타자 8명 이상이면 발표로 간주.
"""
import httpx
from .baseball_details import UA, naver_game_id_candidates
async with httpx.AsyncClient(timeout=15) as client:
for gid in naver_game_id_candidates(m):
try:
r = await client.get(
f"{settings.naver_api_base}/schedule/games/{gid}/preview",
headers=UA,
)
r.raise_for_status()
data = r.json()
except Exception: # noqa: BLE001 — gameId 후보 불일치는 다음 후보로
continue
if not data.get("success"):
continue
p = (data.get("result") or {}).get("previewData") or {}
def batters(key: str) -> list[dict]:
fl = ((p.get(key) or {}).get("fullLineUp")) or []
return [
e for e in fl
if e.get("playerName") and e.get("positionName") != "선발투수"
]
away, home = batters("awayTeamLineUp"), batters("homeTeamLineUp")
return {
"away": away,
"home": home,
"announced": len(away) >= 8 and len(home) >= 8,
}
return None
def _lineup_line(lineups: dict | None, side: str) -> str | None:
if not lineups or not lineups.get("announced"):
return None
mine = lineups["away" if side == "a" else "home"]
names = ", ".join(
f"{i + 1}번 {e['playerName']}({e.get('positionName', '')})"
for i, e in enumerate(mine[:10])
)
return f"오늘 확정 선발 라인업: {names}" if names else None
async def build_context(
db, m: Match, side: str, lineups: dict | None = None
) -> tuple[str, str, str]:
"""(팀코드, 팀명, 맥락 텍스트). side = 'a'(원정) | 'b'(홈).""" """(팀코드, 팀명, 맥락 텍스트). side = 'a'(원정) | 'b'(홈)."""
code = m.team_a_code if side == "a" else m.team_b_code code = m.team_a_code if side == "a" else m.team_b_code
name = m.team_a_short if side == "a" else m.team_b_short name = m.team_a_short if side == "a" else m.team_b_short
@ -207,6 +260,9 @@ async def build_context(db, m: Match, side: str) -> tuple[str, str, str]:
series = await _series_line(db, m) series = await _series_line(db, m)
if series: if series:
lines.append(f"오늘은 {opp_name}와의 {series}") lines.append(f"오늘은 {opp_name}와의 {series}")
lu = _lineup_line(lineups, side)
if lu:
lines.append(lu)
return code, name, "\n".join(x for x in lines if x) return code, name, "\n".join(x for x in lines if x)
@ -278,15 +334,22 @@ def _enabled() -> bool:
) )
async def _start_one(db, m: Match, side: str) -> bool: async def _start_one(db, m: Match, side: str, lineups: dict | None) -> bool:
code, name, context = await build_context(db, m, side) announced = bool(lineups and lineups.get("announced"))
code, name, context = await build_context(db, m, side, lineups)
row = ( row = (
await db.execute( await db.execute(
select(Song).where(Song.match_id == m.match_id, Song.team_code == code) select(Song).where(Song.match_id == m.match_id, Song.team_code == code)
) )
).scalars().first() ).scalars().first()
if row and (row.status != "failed" or row.attempts >= MAX_ATTEMPTS): if row:
return False if row.attempts >= MAX_ATTEMPTS:
return False
if row.status == "generating":
return False
# complete 은 '라인업 없이 만든 곡 + 라인업 발표됨'일 때만 재생성(업그레이드)
if row.status == "complete" and (row.with_lineup or not announced):
return False
piece = await write_lyrics(name, context) piece = await write_lyrics(name, context)
title = str(piece.get("title") or f"{name} 오늘의 응원가").strip()[:40] title = str(piece.get("title") or f"{name} 오늘의 응원가").strip()[:40]
@ -296,6 +359,7 @@ async def _start_one(db, m: Match, side: str) -> bool:
raise RuntimeError("LLM 가사 비어있음") raise RuntimeError("LLM 가사 비어있음")
task_id = await suno.start_generation(title, style, lyrics) task_id = await suno.start_generation(title, style, lyrics)
upgrade = bool(row and row.status == "complete")
if row is None: if row is None:
row = Song(match_id=m.match_id, team_code=code) row = Song(match_id=m.match_id, team_code=code)
db.add(row) db.add(row)
@ -308,15 +372,27 @@ async def _start_one(db, m: Match, side: str) -> bool:
row.task_id = task_id row.task_id = task_id
row.status = "generating" row.status = "generating"
row.error = "" row.error = ""
row.tracks = [] row.with_lineup = announced
# 업그레이드는 기존 트랙을 유지 — 새 곡 완성 시점에 교체 (재생 공백 없음)
if not upgrade:
row.tracks = []
row.attempts = (row.attempts or 0) + 1 row.attempts = (row.attempts or 0) + 1
await db.commit() await db.commit()
log.info("song 생성 시작: %s %s (task=%s)", m.match_id, name, task_id) log.info(
"song 생성 시작: %s %s (task=%s, 라인업=%s%s)",
m.match_id, name, task_id, announced, ", 업그레이드" if upgrade else "",
)
return True return True
async def start_due_songs(db, force_today: bool = False) -> int: async def start_due_songs(db, force_today: bool = False) -> int:
"""생성 윈도우에 든 경기의 팀별 응원가 생성 시작. force_today=오늘 전 경기.""" """생성 윈도우에 든 경기의 팀별 응원가 생성 시작.
윈도우 안에서는 라인업 발표를 기다렸다가 생성하고, 킥오프
song_lineup_fallback_minutes_before 전까지 미발표면 라인업 없이 생성한다.
라인업 없이 만든 곡은 발표 후 자동 재생성(업그레이드).
force_today=오늘 전 경기 즉시(라인업 대기 없이) 생성 — 테스트용.
"""
now = now_utc() now = now_utc()
conds = [ conds = [
Match.league == "kbo", Match.league == "kbo",
@ -328,16 +404,24 @@ async def start_due_songs(db, force_today: bool = False) -> int:
started = 0 started = 0
for m in matches: for m in matches:
kick = ensure_aware(m.kickoff_at) kick = ensure_aware(m.kickoff_at)
mins = (kick - now).total_seconds() / 60
if force_today: if force_today:
if kick.astimezone(KST).date() != today: if kick.astimezone(KST).date() != today:
continue continue
else: elif not (0 < mins <= settings.song_generate_minutes_before):
mins = (kick - now).total_seconds() / 60 continue
if not (0 < mins <= settings.song_generate_minutes_before): try:
continue lineups = await fetch_lineups(m)
except Exception as e: # noqa: BLE001
log.warning("lineup 조회 실패 %s: %s", m.match_id, e)
lineups = None
announced = bool(lineups and lineups.get("announced"))
# 윈도우 내 라인업 대기 — 폴백 시점 전엔 발표될 때까지 생성 보류
if not force_today and not announced and mins > settings.song_lineup_fallback_minutes_before:
continue
for side in ("a", "b"): for side in ("a", "b"):
try: try:
if await _start_one(db, m, side): if await _start_one(db, m, side, lineups):
started += 1 started += 1
except Exception as e: # noqa: BLE001 — 팀 단위 독립 실패 except Exception as e: # noqa: BLE001 — 팀 단위 독립 실패
await db.rollback() await db.rollback()
@ -345,6 +429,16 @@ async def start_due_songs(db, force_today: bool = False) -> int:
return started return started
def _mark_failed(row: Song, error: str) -> None:
"""실패 처리 — 업그레이드 중이었으면(이전 트랙 보유) 이전 곡으로 복귀."""
row.error = error
if row.tracks:
row.status = "complete"
row.with_lineup = False # 다음 틱에 업그레이드 재시도 (attempts 상한 내)
else:
row.status = "failed"
async def poll_generating(db) -> int: async def poll_generating(db) -> int:
"""generating 상태 Suno 작업 폴링 → 완료/실패 반영.""" """generating 상태 Suno 작업 폴링 → 완료/실패 반영."""
rows = ( rows = (
@ -366,18 +460,15 @@ async def poll_generating(db) -> int:
done += 1 done += 1
log.info("song 완료: %s %s (%d트랙)", row.match_id, row.team_name, len(tracks)) log.info("song 완료: %s %s (%d트랙)", row.match_id, row.team_name, len(tracks))
else: else:
row.status = "failed" _mark_failed(row, "SUCCESS 인데 트랙 없음")
row.error = "SUCCESS 인데 트랙 없음"
elif status in suno.SUNO_FAILED: elif status in suno.SUNO_FAILED:
row.status = "failed" _mark_failed(row, f"{status}: {data.get('errorMessage') or ''}"[:300])
row.error = f"{status}: {data.get('errorMessage') or ''}"[:300]
log.warning("song 실패: %s %s", row.match_id, row.error) log.warning("song 실패: %s %s", row.match_id, row.error)
else: else:
# 진행 중 — 오래 걸리면 실패 처리 후 재시도 대상으로 # 진행 중 — 오래 걸리면 실패 처리 후 재시도 대상으로
created = ensure_aware(row.created_at) if row.created_at else now_utc() created = ensure_aware(row.created_at) if row.created_at else now_utc()
if (now_utc() - created).total_seconds() > GENERATE_TIMEOUT_MIN * 60: if (now_utc() - created).total_seconds() > GENERATE_TIMEOUT_MIN * 60:
row.status = "failed" _mark_failed(row, f"타임아웃({status})")
row.error = f"타임아웃({status})"
await db.commit() await db.commit()
return done return done