응원가 생성을 라인업 발표 이후로 게이트
- 네이버 preview fullLineUp 조회 — 양팀 타자 8명 이상이면 발표로 간주, 확정 라인업(타순·포지션 실명)을 가사 컨텍스트에 주입 - 생성 윈도우(킥오프 150분 전~) 안에서 발표까지 대기, 킥오프 40분 전까지 미발표면 라인업 없이 폴백 생성 - 라인업 없이 만든 곡은 발표 후 자동 재생성(업그레이드) — 기존 트랙을 유지한 채 새 곡 완성 시점에 교체, 실패 시 이전 곡 복귀 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
390e06113d
commit
0fc7598d88
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -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)
|
||||||
|
|
||||||
|
|||||||
@ -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")
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user