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), session: AsyncSession = Depends(get_session),
) -> YoutubeDescriptionResponse: ) -> YoutubeDescriptionResponse:
return await seo_service.get_youtube_seo_description( 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 스키마 소셜 SEO 관련 Pydantic 스키마
""" """
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field from pydantic import BaseModel, ConfigDict, Field
class YoutubeDescriptionRequest(BaseModel): class YoutubeDescriptionRequest(BaseModel):
"""유튜브 SEO Description 제안 요청""" """유튜브 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( model_config = ConfigDict(
json_schema_extra={ json_schema_extra={

View File

@ -38,26 +38,93 @@ class SeoService:
task_id: str, task_id: str,
current_user: User, current_user: User,
session: AsyncSession, session: AsyncSession,
content_type: str = "video",
) -> YoutubeDescriptionResponse: ) -> YoutubeDescriptionResponse:
""" """
유튜브 SEO description 생성 유튜브 SEO description 생성
Redis 캐시 확인 후 miss이면 GPT로 생성하고 캐싱. 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( 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: if cached:
return cached return cached
logger.info(f"[SEO_SERVICE] Cache miss - user: {current_user.user_uuid}") 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) 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 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( async def _generate_seo_description(
self, self,
task_id: str, task_id: str,

View File

@ -113,6 +113,32 @@ image_autotag_prompt = Prompt(
prompt_output_class=ImageTagPromptOutput, 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() @lru_cache()
def create_dynamic_subtitle_prompt(length: int, industry: str = "") -> Prompt: def create_dynamic_subtitle_prompt(length: int, industry: str = "") -> Prompt:
# industry 인자는 캐시 구분/하위 호환용. 시트는 단일 'subtitle'로 통합됨. # industry 인자는 캐시 구분/하위 호환용. 시트는 단일 'subtitle'로 통합됨.

View File

@ -1,5 +1,6 @@
from .lyric import LyricPromptInput, LyricPromptOutput from .lyric import LyricPromptInput, LyricPromptOutput
from .marketing import MarketingPromptInput, MarketingPromptOutput from .marketing import MarketingPromptInput, MarketingPromptOutput
from .ssulbox import SsulUploadPromptInput, SsulUploadPromptOutput
from .youtube import YTUploadPromptInput, YTUploadPromptOutput from .youtube import YTUploadPromptInput, YTUploadPromptOutput
from .image import * from .image import *
from .subtitle import SubtitlePromptInput, SubtitlePromptOutput 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]` 라인을 본다.