playreel/backend/answers/narration_answer.py

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