o2o-ado2-short-form/server/app/pipeline/spec_builder.py

172 lines
7.3 KiB
Python

"""① LLM step — interview answers → VideoSpec (JSON).
Uses the Anthropic Python SDK (claude-opus-4-7) with structured outputs so the
result is guaranteed to match the Remotion data contract. The guardrail system
prompt is cached (stable prefix) to cut cost/latency across requests.
"""
from __future__ import annotations
import json
import anthropic
from app.schemas import GenerateRequest, VideoSpec, ScriptResult
MODEL = "claude-opus-4-7"
# Stable, cacheable guardrail + role prompt.
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 tv_spot용. 빠르고 역동적인 카메라(quick punchy moves, crash zoom, kinetic glide). 유형 반영: place=공간 투어, product=제품 쇼케이스. 반드시 "keep structure realistic, do not distort" 포함.
"""
# Structured-output JSON schema (additionalProperties:false everywhere).
_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 build_spec(req: GenerateRequest, client: anthropic.Anthropic | None = None) -> VideoSpec:
client = client or anthropic.Anthropic()
user_msg = (
f"유형: {'장소' if req.kind == 'place' else '메시지(축하·안부·홍보)' if req.kind == 'message' else '물건(제품·음식 등 정적 아이템)'}\n"
f"업체/상품명: {req.biz_name}\n"
f"주소/판매URL: {req.addr or '(없음)'}\n"
f"가격: {req.price or '(없음)'}\n"
f"강력한 한방 셀링포인트: {req.selling}\n\n"
"위 정보로 VideoSpec을 설계해줘."
)
resp = client.messages.create(
model=MODEL,
max_tokens=2048,
thinking={"type": "adaptive"},
output_config={
"effort": "medium",
"format": {"type": "json_schema", "name": "video_spec", "schema": _SPEC_SCHEMA},
},
system=[{
"type": "text",
"text": SYSTEM_PROMPT,
"cache_control": {"type": "ephemeral"},
}],
messages=[{"role": "user", "content": user_msg}],
)
text = "".join(b.text for b in resp.content if b.type == "text")
return VideoSpec.model_validate(json.loads(text))
# ---------- 자막 스크립트 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: anthropic.Anthropic | None = None) -> ScriptResult:
client = client or anthropic.Anthropic()
user_msg = (
f"유형: {'장소' if req.kind == 'place' else '메시지(축하·안부·홍보)' if req.kind == 'message' else '물건(제품·음식 등 정적 아이템)'}\n"
f"업체/상품명: {req.biz_name}\n"
f"주소/판매URL: {req.addr or '(없음)'}\n"
f"가격: {req.price or '(없음)'}\n"
f"강력한 한방 셀링포인트: {req.selling}\n\n"
"위 정보로 4블록 자막 스크립트를 만들어줘."
)
resp = client.messages.create(
model=MODEL,
max_tokens=1024,
thinking={"type": "adaptive"},
output_config={
"effort": "low",
"format": {"type": "json_schema", "name": "script", "schema": _SCRIPT_SCHEMA},
},
system=[{"type": "text", "text": SCRIPT_SYSTEM, "cache_control": {"type": "ephemeral"}}],
messages=[{"role": "user", "content": user_msg}],
)
text = "".join(b.text for b in resp.content if b.type == "text")
return ScriptResult.model_validate(json.loads(text))