feat(ssulbox): SNS 업로드 제목·설명·태그 자동 생성

This commit is contained in:
김성경 2026-08-11 11:03:04 +09:00
parent fb8febe462
commit 50dc7af5bc
7 changed files with 234 additions and 6 deletions

View File

@ -33,5 +33,8 @@ async def youtube_seo_description(
session: AsyncSession = Depends(get_session),
) -> YoutubeDescriptionResponse:
return await seo_service.get_youtube_seo_description(
request_body.task_id, current_user, session
request_body.task_id,
current_user,
session,
content_type=request_body.content_type,
)

View File

@ -2,13 +2,20 @@
소셜 SEO 관련 Pydantic 스키마
"""
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field
class YoutubeDescriptionRequest(BaseModel):
"""유튜브 SEO Description 제안 요청"""
task_id: str = Field(..., description="작업 고유 식별자")
# 썰박스 콘텐츠의 SEO 를 요청할 때는 "ssul" + task_id 자리에 ssul_content.id.
# 종류를 안 밝히면 video 로 간주된다 (기존 호출 호환).
content_type: Literal["video", "ssul"] = Field(
default="video", description="콘텐츠 종류"
)
task_id: str = Field(..., description="작업 고유 식별자 (ssul 이면 ssul_content.id)")
model_config = ConfigDict(
json_schema_extra={

View File

@ -38,26 +38,93 @@ class SeoService:
task_id: str,
current_user: User,
session: AsyncSession,
content_type: str = "video",
) -> YoutubeDescriptionResponse:
"""
유튜브 SEO description 생성
Redis 캐시 확인 후 miss이면 GPT로 생성하고 캐싱.
content_type="ssul" 이면 task_id 자리에 ssul_content.id(문자열)가 온다.
캐시 키에 종류 접두를 붙인다 — ADO2 task_id(UUID)와 썰박스 id(숫자)는
형식이 달라 실제로 겹치진 않지만, 형식 우연에 기대지 않는다.
"""
cache_key = f"ssul:{task_id}" if content_type == "ssul" else task_id
logger.info(
f"[SEO_SERVICE] Try Cache - user: {current_user.user_uuid} / task_id: {task_id}"
f"[SEO_SERVICE] Try Cache - user: {current_user.user_uuid} / key: {cache_key}"
)
cached = await self._get_from_redis(task_id)
cached = await self._get_from_redis(cache_key)
if cached:
return cached
logger.info(f"[SEO_SERVICE] Cache miss - user: {current_user.user_uuid}")
if content_type == "ssul":
result = await self._generate_ssul_seo(task_id, current_user, session)
else:
result = await self._generate_seo_description(task_id, current_user, session)
await self._set_to_redis(task_id, result)
await self._set_to_redis(cache_key, result)
return result
async def _generate_ssul_seo(
self,
content_id: str,
current_user: User,
session: AsyncSession,
) -> YoutubeDescriptionResponse:
"""썰박스 콘텐츠용 SEO 생성 — ADO2 와 **다른 프롬프트**(시트 ssul_upload)를 쓴다.
ADO2 는 업장 마케팅 분석 보고서 기반의 광고 영상 SEO 지만, 썰박스는
병맛 역사 썰툰이라 톤이 완전히 다르다. 태그도 GPT 가 함께 만든다
(ADO2 처럼 재사용할 마케팅 분석 target_keywords 가 없다).
"""
from app.ssulbox.constants import SCENARIO_NAMES
from app.ssulbox.models import SsulContent
from app.utils.prompts.prompts import get_ssul_upload_prompt
try:
content = (
await session.execute(
select(SsulContent).where(
SsulContent.id == int(content_id),
SsulContent.user_uuid == current_user.user_uuid,
SsulContent.is_deleted.is_(False),
)
)
).scalar_one_or_none()
if content is None:
raise HTTPException(
status_code=404, detail="콘텐츠를 찾을 수 없습니다."
)
input_data = {
"store_name": content.store_name or "",
"region": content.region or "",
"scenario_name": SCENARIO_NAMES.get(content.scenario, content.scenario),
}
chatgpt = ChatgptService(timeout=180)
out = await chatgpt.generate_structured_output(
get_ssul_upload_prompt(), input_data
)
return YoutubeDescriptionResponse(
title=out.title,
description=out.description,
keywords=out.keywords,
)
except HTTPException:
raise
except Exception as e:
logger.error(f"[SEO_SERVICE] SSUL EXCEPTION - error: {e}")
raise HTTPException(
status_code=500,
detail=f"썰박스 SEO 생성에 실패했습니다. : {str(e)}",
)
async def _generate_seo_description(
self,
task_id: str,

View File

@ -113,6 +113,32 @@ image_autotag_prompt = Prompt(
prompt_output_class=ImageTagPromptOutput,
)
@lru_cache()
def get_ssul_upload_prompt() -> Prompt:
"""썰박스 SNS 업로드 SEO 프롬프트 (시트: ssul_upload).
**lazy 로 만드는 이유**: 모듈 레벨 Prompt() 는 import 시점에 시트를 읽는다 —
스프레드시트에 `ssul_upload` 시트가 아직 없으면 **앱 기동 자체가 죽는다.**
lazy 면 시트가 없어도 앱은 살고, 썰박스 SEO 요청만 500 이 난다.
(그래서 _preload_all_sheets 목록에도 넣지 않았다 — preload 는 실패를
경고로 삼키지만, 이후 Prompt() 생성 시 worksheet() 폴백에서 죽는 건 같다)
ADO2(yt_upload)와 다른 프롬프트를 쓴다: 광고 영상 SEO 가 아니라
병맛 역사 썰툰 톤의 제목·설명·태그를 만든다.
템플릿 변수: {store_name} {region} {scenario_name}
"""
from app.utils.prompts.schemas import (
SsulUploadPromptInput,
SsulUploadPromptOutput,
)
return Prompt(
sheet_name="ssul_upload",
prompt_input_class=SsulUploadPromptInput,
prompt_output_class=SsulUploadPromptOutput,
)
@lru_cache()
def create_dynamic_subtitle_prompt(length: int, industry: str = "") -> Prompt:
# industry 인자는 캐시 구분/하위 호환용. 시트는 단일 'subtitle'로 통합됨.

View File

@ -1,5 +1,6 @@
from .lyric import LyricPromptInput, LyricPromptOutput
from .marketing import MarketingPromptInput, MarketingPromptOutput
from .ssulbox import SsulUploadPromptInput, SsulUploadPromptOutput
from .youtube import YTUploadPromptInput, YTUploadPromptOutput
from .image import *
from .subtitle import SubtitlePromptInput, SubtitlePromptOutput

View File

@ -0,0 +1,28 @@
from pydantic import BaseModel, Field
from typing import List
# Input 정의
class SsulUploadPromptInput(BaseModel):
"""썰박스 SNS 업로드 SEO 프롬프트 입력.
ADO2(yt_upload)와 별도 프롬프트를 쓴다 — ADO2 는 업장 마케팅 분석 보고서 기반의
광고 영상이지만, 썰박스는 "병맛 역사 썰툰"이라 제목·설명의 톤이 완전히 다르다.
스프레드시트 시트명: `ssul_upload` (B2=모델, B3=템플릿).
템플릿에서 쓸 수 있는 변수: {store_name} {region} {scenario_name}
"""
store_name: str = Field(..., description="마케팅 대상 업장명 (모르면 빈 문자열)")
region: str = Field(default="", description="업장 지역 (모르면 빈 문자열)")
scenario_name: str = Field(
..., description="시나리오 한글명 (조선왕/삼국지/그리스·로마 신화/오디세이)"
)
# Output 정의
class SsulUploadPromptOutput(BaseModel):
title: str = Field(..., description="쇼츠 제목 - 병맛 톤 + SEO")
description: str = Field(..., description="업로드 설명 - 병맛 톤 + SEO/해시태그 포함")
# ADO2 는 마케팅 분석의 target_keywords 를 태그로 재사용하지만 썰박스에는
# 그 분석이 없다 — GPT 가 태그까지 함께 만든다.
keywords: List[str] = Field(..., description="태그 키워드 리스트")

View File

@ -0,0 +1,96 @@
# `ssul_upload` 프롬프트 (스프레드시트 시트용)
썰박스 콘텐츠의 SNS 업로드 제목·설명·태그를 만드는 프롬프트다.
ADO2 의 `yt_upload` 와 **별도 시트**를 쓴다 — 그쪽은 업장 마케팅 분석 기반 광고
영상 SEO 지만, 썰박스는 병맛 역사 썰툰이라 톤이 완전히 다르다.
## 시트 작성 방법
`PROMPT_SPREADSHEET` 스프레드시트에 **`ssul_upload`** 시트를 만들고:
| 셀 | 값 |
|---|---|
| **B2** | 모델명 (`yt_upload` 과 동일하게 두면 된다. 2026-07-31 기준 `gpt-5.4-mini-2026-03-17`) |
| **B3** | 아래 "프롬프트 본문" 전체 |
## 입력 변수 (3개뿐)
| 변수 | 예시 | 비어 있을 수 있는가 |
|---|---|---|
| `{store_name}` | `골목냉면` | **예** — place URL 을 붙여넣고 크롤링이 실패한 경우 |
| `{region}` | `서울시` | **예** — 위와 동일 |
| `{scenario_name}` | `조선왕` | 아니오 (항상 채워짐) |
## 설계 시 지킨 제약
**1. 메뉴·리뷰 정보가 없다.**
generator 는 네이버 브리핑·리뷰를 크롤링해 풍부한 소재를 갖지만, 이 프롬프트의
입력은 위 3개뿐이다. 그래서 엔진 캡션 형식의 "🔥 대표 메뉴·포인트" 줄은 **뺐다** —
없는 정보를 지어내면 실제 가게와 다른 허위 홍보가 된다.
**2. 길이 제한이 실재한다.**
`SocialUploadRequest.title` 이 `max_length=100` 이라 100자를 넘기면 업로드 요청이
422 로 거부된다. 설명은 5000자, 태그 입력란은 500자다. 프롬프트에 명시했다.
**3. 업장명이 없을 수 있다.**
그 경우 가게 언급 없이 시나리오만으로 쓰도록 지시했다. `'{store_name}'` 처럼
빈 값이 그대로 노출되면 안 된다.
---
## 프롬프트 본문 (B3 에 붙여넣기)
```
너는 "병맛 역사 썰툰" 쇼츠의 SNS 업로드 문구를 쓰는 카피라이터다.
아래 정보로 유튜브 쇼츠 업로드용 제목·설명·해시태그를 만들어라.
[콘텐츠 정보]
- 시나리오: {scenario_name}
- 가게 이름: {store_name}
- 지역: {region}
[시나리오별 톤 가이드]
- 조선왕: 조선 27대 왕들의 실화 썰. 대표 인물 세종·태종·연산군 등. 태그 앞에 #조선왕 #역사썰
- 삼국지: 위·촉·오 영웅들의 야사. 대표 인물 제갈량·조조·관우 등. 태그 앞에 #삼국지 #역사썰
- 그리스·로마 신화: 올림포스 신·영웅들의 전설. 대표 인물 제우스·헤라클레스 등. 태그 앞에 #그리스로마 #신화썰
- 오디세이: 오디세우스 10년 귀향 대모험. 대표 인물 오디세우스·포세이돈 등. 태그 앞에 #오디세이 #오디세우스
[title — 쇼츠 제목]
- **100자 이내** (반드시 지킬 것. 초과하면 업로드가 거부된다)
- 짧고 자극적인 후킹 문구. 예: "세종대왕이 사실 이 집 단골이었다는 썰"
- 해시태그(#)를 넣지 말 것. 태그는 keywords 에만 넣는다.
- 가게 이름이 있으면 자연스럽게 녹여라. 없으면 시나리오만으로 써라.
[description — 업로드 설명]
- 5000자 이내. 이모지를 적극 사용하고 줄바꿈으로 구분한다.
- 형식:
1) 첫 줄: 후킹 카피 한 줄
2) 본문 1~2줄: {scenario_name} 썰과 가게를 잇는 재밌는 소개 (이모지 곁들여)
3) 셀링 한 줄: 그 시대 인물이 반할 법한 식으로 가게를 띄우는 한 줄
4) 📍 가게명 (지역) ← 가게 이름이나 지역을 모르면 이 줄을 통째로 생략
5) 👉 방문 유도 한 줄
6) 마지막 줄: 해시태그 나열 (시나리오 태그를 앞에, #shorts #AIO2O 로 끝)
- **모르는 정보는 절대 지어내지 마라.** 메뉴·영업시간·가격·전화번호처럼
주어지지 않은 정보는 언급하지 않는다. 가게 이름이 비어 있으면 가게 얘기를
빼고 시나리오 콘텐츠 소개로만 채운다.
[keywords — 해시태그용 키워드 배열]
- 8~12개. 각 항목은 **# 없이** 순수 키워드 문자열로 넣어라 (예: "조선왕", "역사썰").
- 구성: 시나리오 태그 2개 → 가게명·지역(있을 때만) → 병맛/역사 관련 → shorts, AIO2O
- 전체를 쉼표로 이었을 때 500자를 넘기지 마라.
```
---
## 확인 방법
시트를 만든 뒤:
```bash
# 앱 재기동 없이도 되지만, 프롬프트는 lru_cache 라 캐시가 남아 있으면 재기동 필요
docker restart castad-app
```
프론트에서 썰박스 콘텐츠의 **업로드 버튼**을 누르면 제목·설명·태그가 자동으로
채워진다. 실패하면 조용히 빈 칸으로 열리므로(사용자 흐름을 막지 않는다),
동작 확인은 백엔드 로그의 `[SEO_SERVICE]` 라인을 본다.