"""① 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)