81 lines
2.9 KiB
Python
81 lines
2.9 KiB
Python
"""② narration_text 단계 LLM 응답 스키마.
|
|
|
|
검증 실패는 재생성 피드백으로 그대로 쓰이므로, 에러 메시지에 사유를 적는다.
|
|
"""
|
|
import re
|
|
from typing import Literal
|
|
|
|
from pydantic import BaseModel, field_validator
|
|
|
|
MAX_NARRATION_CHARS = 40
|
|
# TTS가 소리로 읽어버리는 문자 — 있으면 "물결", "점", "괄호"로 발음된다
|
|
SPEAK_BAN = re.compile(r"[~()\[\]/:]|\d\s*\.")
|
|
BGM_PROMPT_PREFIX = "starts hard on beat one"
|
|
|
|
|
|
class BgmStyle(BaseModel):
|
|
style: str
|
|
prompt: str
|
|
|
|
@field_validator("style")
|
|
@classmethod
|
|
def must_pin_down_sound(cls, style: str) -> str:
|
|
if not re.search(r"\d{2,3}\s*bpm", style, re.I):
|
|
raise ValueError(f"style에 숫자 bpm이 없음 (스톡 사운드 방지): {style!r}")
|
|
if "instrumental" not in style.lower():
|
|
raise ValueError(f"style에 instrumental이 없음 (보컬이 나레이션과 겹친다): {style!r}")
|
|
return style
|
|
|
|
@field_validator("prompt")
|
|
@classmethod
|
|
def must_start_on_beat_one(cls, prompt: str) -> str:
|
|
if not prompt.lower().startswith(BGM_PROMPT_PREFIX):
|
|
raise ValueError("prompt는 'Starts hard on beat one - no intro ramp.'로 시작해야 함 "
|
|
f"(Suno 무음 인트로 방지): {prompt!r}")
|
|
return prompt
|
|
|
|
|
|
class NarrationMetadata(BaseModel):
|
|
event_name: str
|
|
date_text: str
|
|
place: str
|
|
category: Literal["축제", "공연", "전시", "마켓", "기타"]
|
|
keywords: list[str]
|
|
region_guess: str
|
|
|
|
@field_validator("event_name", "place")
|
|
@classmethod
|
|
def must_not_be_blank(cls, value: str) -> str:
|
|
if not value.strip():
|
|
raise ValueError("metadata의 event_name·place는 비울 수 없음")
|
|
return value
|
|
|
|
@field_validator("keywords")
|
|
@classmethod
|
|
def must_have_keywords(cls, keywords: list[str]) -> list[str]:
|
|
if not keywords:
|
|
raise ValueError("metadata.keywords가 비어 있음")
|
|
return keywords
|
|
|
|
|
|
class NarrationAnswer(BaseModel):
|
|
narration: list[str]
|
|
voice: Literal["nova", "ash"]
|
|
bgm_style: BgmStyle
|
|
metadata: NarrationMetadata
|
|
|
|
@field_validator("narration")
|
|
@classmethod
|
|
def must_be_three_short_speakable_lines(cls, sentences: list[str]) -> list[str]:
|
|
if len(sentences) != 3:
|
|
raise ValueError(f"narration은 정확히 3문장이어야 함 (받은 문장 수: {len(sentences)})")
|
|
for sentence in sentences:
|
|
if not sentence.strip():
|
|
raise ValueError("빈 나레이션 문장이 있음")
|
|
if len(sentence) > MAX_NARRATION_CHARS:
|
|
raise ValueError(f"나레이션 문장이 {MAX_NARRATION_CHARS}자를 넘음: {sentence!r}")
|
|
if SPEAK_BAN.search(sentence):
|
|
raise ValueError("나레이션에 TTS가 소리내어 읽는 문자가 있음 "
|
|
f"(~ ( ) [ ] / : 숫자뒤 마침표): {sentence!r}")
|
|
return sentences
|