"""② 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