180 lines
7.8 KiB
Python
180 lines
7.8 KiB
Python
"""① LLM step — interview answers → VideoSpec / ScriptResult (JSON).
|
|
|
|
Uses the OpenAI SDK with Structured Outputs (response_format json_schema, strict)
|
|
so the result is guaranteed to match the Remotion data contract. The stable
|
|
guardrail system prompt benefits from OpenAI's automatic prompt caching.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from openai import OpenAI
|
|
|
|
from app import config as cfg
|
|
from app.schemas import GenerateRequest, VideoSpec, ScriptResult
|
|
|
|
MODEL = cfg.OPENAI_MODEL
|
|
|
|
# Stable guardrail + role prompt (automatic prompt caching applies to this prefix).
|
|
SYSTEM_PROMPT = """\
|
|
당신은 한국 로컬 비즈니스(숙소·매장·제품)의 8초 세로형 숏폼 광고 카피 디렉터입니다.
|
|
사장/마케터가 직접 답한 정보만으로, 영상 사양(VideoSpec)을 설계합니다. 복잡한 분석은 하지 않습니다.
|
|
|
|
[절대 가드레일]
|
|
1. 식민지 유산·역사정치 민감 용어 금지: "적산가옥", "일제", "식민지", "근대사" 등 → 미학·경험 어휘로 대체.
|
|
2. 거짓·과장 금지: 사용자가 주지 않은 거리(예: "서울 40분")·효능·수치를 지어내지 않는다. 주어진 정보만 사용.
|
|
3. 자막은 흰색 모노크롬 전제 — 카피에 색 지시어를 넣지 않는다.
|
|
4. AI 디스클로저는 end_card.disclosure에 고정 문구로 포함.
|
|
|
|
[hook 작성 — 레퍼런스 바이럴 공식 "[반전/호기심] + [한방]"]
|
|
- eyebrow: 짧은 도발/맥락 한 줄 (예: "아무도 모르는 군산").
|
|
- title: 굵은 한방 헤드라인. 사용자의 셀링포인트를 가장 강하게 압축.
|
|
|
|
[selling_point.items] 정확히 3개. 사용자 한방 셀링포인트 + 유형 기반의 구체 명사구(짧게). 이모지 금지.
|
|
|
|
[brand_lines] 감성 2줄 (각 줄 짧게). 한 덩어리로 읽히게.
|
|
|
|
[end_card] brand=업체/상품명, location=주소나 URL(없으면 빈 문자열 ""), disclosure 고정.
|
|
|
|
[caption] 업로드용. 정보(업체/주소/가격) + 짧은 감성 + 해시태그 5~8개. 유형에 맞는 해시태그.
|
|
|
|
[profile 선택]
|
|
- place + 정적/프라이빗/힐링 → "Still Cinema"
|
|
- place + 뷰/활동성/포토스팟, 또는 product 일반 → "Rhythm Reveal"
|
|
- 파티/워터파크/대형 액티비티 → "Maximum Viral"
|
|
|
|
[higgsfield_prompt] 영문. marketing_studio_video 용 고품질 프롬프트. 아래 구조를 반드시 따를 것.
|
|
|
|
① 포맷 명시: "VERTICAL 9:16 portrait format, mobile short-form."
|
|
② 카메라 무브 (place 유형): 드론/핸드헬드 하이브리드로 실내외를 역동적으로 순회.
|
|
예시 패턴: "smooth drone glide through entrance → quick crash-zoom onto hero detail → kinetic handheld reveal of interior space → pull-back drone shot of exterior"
|
|
product 유형: "macro crash-zoom onto product → slow orbit reveal → snappy cut to detail closeup"
|
|
③ 분위기: 시간대(twilight/golden hour/daylight)·색감(teal-and-orange grade 또는 유형에 맞는 톤)·질감(subtle film grain, quiet luxury)을 구체적으로 명시.
|
|
④ 반드시 포함할 문구: "photorealistic, no AI artifacts, keep architecture and objects structurally accurate, do not warp or distort walls floors or furniture, no morphing, no hallucinated details."
|
|
⑤ 마지막 문장: "Hook viewer in first second. Beat-driven energetic edit."
|
|
유형(place/product/message)과 업체 분위기에 맞게 세부 내용을 구체화할 것. 절대 짧거나 추상적인 문장으로 끝내지 말 것.
|
|
"""
|
|
|
|
# Structured-output JSON schema (strict: additionalProperties:false + all props required).
|
|
_SPEC_SCHEMA = {
|
|
"type": "object",
|
|
"additionalProperties": False,
|
|
"properties": {
|
|
"profile": {"type": "string", "enum": ["Still Cinema", "Rhythm Reveal", "Maximum Viral"]},
|
|
"higgsfield_prompt": {"type": "string"},
|
|
"hook": {
|
|
"type": "object",
|
|
"additionalProperties": False,
|
|
"properties": {"eyebrow": {"type": "string"}, "title": {"type": "string"}},
|
|
"required": ["eyebrow", "title"],
|
|
},
|
|
"selling_point": {
|
|
"type": "object",
|
|
"additionalProperties": False,
|
|
"properties": {"items": {"type": "array", "items": {"type": "string"}}},
|
|
"required": ["items"],
|
|
},
|
|
"brand_lines": {"type": "array", "items": {"type": "string"}},
|
|
"end_card": {
|
|
"type": "object",
|
|
"additionalProperties": False,
|
|
"properties": {
|
|
"brand": {"type": "string"},
|
|
"location": {"type": "string"},
|
|
"disclosure": {"type": "string"},
|
|
},
|
|
"required": ["brand", "location", "disclosure"],
|
|
},
|
|
"caption": {"type": "string"},
|
|
},
|
|
"required": [
|
|
"profile", "higgsfield_prompt", "hook", "selling_point",
|
|
"brand_lines", "end_card", "caption",
|
|
],
|
|
}
|
|
|
|
|
|
def _kind_label(kind: str) -> str:
|
|
return {
|
|
"place": "장소",
|
|
"message": "메시지(축하·안부·홍보)",
|
|
"product": "물건(제품·음식 등 정적 아이템)",
|
|
}.get(kind, kind)
|
|
|
|
|
|
def _user_msg(req: GenerateRequest, tail: str) -> str:
|
|
return (
|
|
f"유형: {_kind_label(req.kind)}\n"
|
|
f"업체/상품명: {req.biz_name}\n"
|
|
f"주소/판매URL: {req.addr or '(없음)'}\n"
|
|
f"가격: {req.price or '(없음)'}\n"
|
|
f"강력한 한방 셀링포인트: {req.selling}\n\n"
|
|
f"{tail}"
|
|
)
|
|
|
|
|
|
def _complete(client: OpenAI, system: str, user: str, schema_name: str,
|
|
schema: dict, max_tokens: int) -> dict:
|
|
"""OpenAI Structured Outputs 호출 → 검증된 JSON(dict) 반환."""
|
|
resp = client.chat.completions.create(
|
|
model=MODEL,
|
|
max_completion_tokens=max_tokens,
|
|
response_format={
|
|
"type": "json_schema",
|
|
"json_schema": {"name": schema_name, "strict": True, "schema": schema},
|
|
},
|
|
messages=[
|
|
{"role": "system", "content": system},
|
|
{"role": "user", "content": user},
|
|
],
|
|
)
|
|
return json.loads(resp.choices[0].message.content)
|
|
|
|
|
|
def build_spec(req: GenerateRequest, client: OpenAI | None = None) -> VideoSpec:
|
|
client = client or OpenAI()
|
|
data = _complete(
|
|
client, SYSTEM_PROMPT, _user_msg(req, "위 정보로 VideoSpec을 설계해줘."),
|
|
"video_spec", _SPEC_SCHEMA, max_tokens=2048,
|
|
)
|
|
return VideoSpec.model_validate(data)
|
|
|
|
|
|
# ---------- 자막 스크립트 4블록 ----------
|
|
SCRIPT_SYSTEM = """\
|
|
당신은 한국 로컬 비즈니스 숏폼 광고의 자막 스크립트 작가입니다.
|
|
사장/마케터가 답한 정보로, 영상에 얹을 4개 블록을 만듭니다.
|
|
|
|
[4블록]
|
|
- intro: 첫 1~2초 후킹. 호기심·반전을 거는 짧은 한 줄.
|
|
- selling: 가장 강력한 셀링포인트를 한 문장으로 압축.
|
|
- story: 감성 스토리. 고객이 그 공간/제품에서 느낄 감정을 1~2문장으로.
|
|
- cta: 행동 유도. 예약·구매·방문으로 자연스럽게.
|
|
|
|
[가드레일]
|
|
- 거짓·과장 금지(주어지지 않은 거리·효능·수치 X). 주어진 정보만.
|
|
- 식민지 유산 어휘(적산가옥/일제/식민지 등) 금지 → 미학·경험 어휘로.
|
|
- 자막은 흰색 모노크롬 전제 — 색 지시어 넣지 않음. 이모지 남발 금지.
|
|
- 각 블록은 짧고 입에 붙게. 한국어.
|
|
"""
|
|
|
|
_SCRIPT_SCHEMA = {
|
|
"type": "object",
|
|
"additionalProperties": False,
|
|
"properties": {
|
|
"intro": {"type": "string"},
|
|
"selling": {"type": "string"},
|
|
"story": {"type": "string"},
|
|
"cta": {"type": "string"},
|
|
},
|
|
"required": ["intro", "selling", "story", "cta"],
|
|
}
|
|
|
|
|
|
def build_script(req: GenerateRequest, client: OpenAI | None = None) -> ScriptResult:
|
|
client = client or OpenAI()
|
|
data = _complete(
|
|
client, SCRIPT_SYSTEM, _user_msg(req, "위 정보로 4블록 자막 스크립트를 만들어줘."),
|
|
"script", _SCRIPT_SCHEMA, max_tokens=1024,
|
|
)
|
|
return ScriptResult.model_validate(data)
|