가사 품질 보강 — 한국어 수사 규칙·전체 분량·캐시 TTL

- 서수 고유어(4번째→네 번째), 고유어 조수사(방·개·명·골), 아웃 카운트
  영어 수사(2아웃→투아웃)
- 가사 축약 방지: 필수 섹션 명시(Verse2·Bridge·Final Chorus), max_tokens
  3000→8000, 450자 미만은 재시도
- 프론트 곡 캐시 하루→5분 TTL (당일 재생성 교체 반영)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
jwkim 2026-08-27 11:40:45 +09:00
parent cb00945612
commit 746eaced80
2 changed files with 29 additions and 6 deletions

View File

@ -468,7 +468,8 @@ def _lyrics_prompt(team_name: str, context: str, league: str = "kbo") -> str:
"가사에 구체적으로 녹일 것 — 선수 실명 사용 가능\n"
"- 숫자는 자연스럽게 표기 (13:1, 78승, 2차전 등 — 발음 변환은 시스템이 처리)\n"
"- 괄호로 관중 콜앤리스폰스 파트 표기, 섹션 태그에 연주 지시 포함\n"
"- 분량은 1분 30초~2분 (Verse 2개 + Chorus 반복 + Bridge + Final Chorus)\n\n"
"- 섹션 구성 필수: [Intro] → [Verse 1] → [Chorus] → [Verse 2] → [Chorus] → "
"[Bridge] → [Final Chorus] → [Outro] 전부 포함 (생략 금지), 가사 본문 700자 이상\n\n"
f"[가사 형식 레퍼런스 — 구조와 톤만 참고, 내용은 오늘 경기에 맞게 새로 쓸 것]\n{_REFERENCE}\n\n"
"다음 키를 가진 JSON 객체 하나만 출력하라:\n"
' "title": 곡 제목 (한국어, 25자 이내, 오늘 경기 느낌이 나게),\n'
@ -505,6 +506,10 @@ def _native(n: int) -> str:
return _NATIVE.get(n) or _sino(n)
# 서수 관형형 — 1~4는 첫/두/세/네, 이후는 고유어 수사 그대로 (다섯 번째)
_ORDINAL = {1: "", 2: "", 3: "", 4: ""}
def hangulize_numbers(text: str) -> str:
"""가사 속 아라비아 숫자를 한글 발음으로 치환 (Suno 전송본 전용).
@ -530,9 +535,21 @@ def _hangulize_plain(text: str) -> str:
lambda m: f"{_sino(int(m.group(1)))}{_sino(int(m.group(2)))}",
text,
)
# 게임·경기 수는 고유어 수사 (7게임 → 일곱 게임)
# 아웃 카운트는 야구 관례상 영어 수사 (2아웃→투아웃)
text = re.sub(
r"(\d+)\s*(게임|경기)",
r"([123])\s*아웃",
lambda m: {1: "", 2: "", 3: "쓰리"}[int(m.group(1))] + "아웃",
text,
)
# 서수는 관형 고유어 수사 (1번째→첫 번째, 4번째→네 번째, 5번째→다섯 번째)
text = re.sub(
r"(\d+)\s*번째",
lambda m: (_ORDINAL.get(int(m.group(1))) or _native(int(m.group(1)))) + " 번째",
text,
)
# 고유어 조수사 (7게임→일곱 게임, 2방→두 방, 3개→세 개)
text = re.sub(
r"(\d+)\s*(게임|경기|개|명|방|골|마리|살|바퀴)",
lambda m: f"{_native(int(m.group(1)))} {m.group(2)}",
text,
)
@ -556,7 +573,7 @@ async def write_lyrics(team_name: str, context: str, league: str = "kbo") -> dic
client = AsyncAnthropic(api_key=settings.anthropic_api_key)
msg = await client.messages.create(
model=settings.anthropic_model,
max_tokens=3000,
max_tokens=8000,
messages=[{"role": "user", "content": prompt}],
)
text = "".join(b.text for b in msg.content if getattr(b, "type", "") == "text")
@ -614,6 +631,8 @@ async def _start_one(db, m: Match, side: str, lineups: dict | None) -> bool:
lyrics = str(piece.get("lyrics") or "").strip()
if not lyrics:
raise RuntimeError("LLM 가사 비어있음")
if len(lyrics) < 450:
raise RuntimeError(f"가사 너무 짧음({len(lyrics)}자) — 재시도")
# 표기용(화면 '가사 보기')은 원문 그대로 저장하고,
# Suno 전송본만 숫자를 한글 발음으로 치환 (13:1 → 십삼 대 일)
sung_lyrics = hangulize_numbers(lyrics)

View File

@ -30,6 +30,9 @@ import { getTodaySongs, type SongOut } from "./api";
let songsPromise: Promise<SongOut[]> | null = null;
let songsDay = "";
let songsAt = 0;
// 곡은 라인업 반영 재생성으로 당일 중에도 교체되므로 짧은 TTL 로 재조회
const SONGS_TTL_MS = 5 * 60_000;
function todayKstKey(): string {
return new Date(Date.now() + 9 * 3600_000).toISOString().slice(0, 10);
@ -37,9 +40,10 @@ function todayKstKey(): string {
function fetchSongsCached(): Promise<SongOut[]> {
const day = todayKstKey();
if (!songsPromise || songsDay !== day) {
if (!songsPromise || songsDay !== day || Date.now() - songsAt > SONGS_TTL_MS) {
songsDay = day;
songsPromise = getTodaySongs().catch(() => []); // 전 리그(kbo+mlb)
songsAt = Date.now();
songsPromise = getTodaySongs().catch(() => []); // 전 리그
}
return songsPromise;
}