feat(ssulbox): SNS 업로드 제목·설명·태그 자동 생성
This commit is contained in:
parent
fb8febe462
commit
50dc7af5bc
@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
@ -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={
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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'로 통합됨.
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
28
app/utils/prompts/schemas/ssulbox.py
Normal file
28
app/utils/prompts/schemas/ssulbox.py
Normal 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="태그 키워드 리스트")
|
||||||
96
docs/prompts/ssul_upload.md
Normal file
96
docs/prompts/ssul_upload.md
Normal 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]` 라인을 본다.
|
||||||
Loading…
Reference in New Issue
Block a user