From 50dc7af5bc167849c591566a2cae2dc9a962b568 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=84=B1=EA=B2=BD?= Date: Tue, 11 Aug 2026 11:03:04 +0900 Subject: [PATCH] =?UTF-8?q?feat(ssulbox):=20SNS=20=EC=97=85=EB=A1=9C?= =?UTF-8?q?=EB=93=9C=20=EC=A0=9C=EB=AA=A9=C2=B7=EC=84=A4=EB=AA=85=C2=B7?= =?UTF-8?q?=ED=83=9C=EA=B7=B8=20=EC=9E=90=EB=8F=99=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/social/api/routers/v1/seo.py | 5 +- app/social/schemas/seo_schema.py | 9 ++- app/social/services/seo_service.py | 75 +++++++++++++++++++-- app/utils/prompts/prompts.py | 26 ++++++++ app/utils/prompts/schemas/__init__.py | 1 + app/utils/prompts/schemas/ssulbox.py | 28 ++++++++ docs/prompts/ssul_upload.md | 96 +++++++++++++++++++++++++++ 7 files changed, 234 insertions(+), 6 deletions(-) create mode 100644 app/utils/prompts/schemas/ssulbox.py create mode 100644 docs/prompts/ssul_upload.md diff --git a/app/social/api/routers/v1/seo.py b/app/social/api/routers/v1/seo.py index a1fdcf1..f92246e 100644 --- a/app/social/api/routers/v1/seo.py +++ b/app/social/api/routers/v1/seo.py @@ -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, ) diff --git a/app/social/schemas/seo_schema.py b/app/social/schemas/seo_schema.py index 74d892d..ee5dee3 100644 --- a/app/social/schemas/seo_schema.py +++ b/app/social/schemas/seo_schema.py @@ -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={ diff --git a/app/social/services/seo_service.py b/app/social/services/seo_service.py index f4d7f93..059cf62 100644 --- a/app/social/services/seo_service.py +++ b/app/social/services/seo_service.py @@ -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}") - result = await self._generate_seo_description(task_id, current_user, session) - await self._set_to_redis(task_id, result) + 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(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, diff --git a/app/utils/prompts/prompts.py b/app/utils/prompts/prompts.py index 9c8f83b..8dddb87 100644 --- a/app/utils/prompts/prompts.py +++ b/app/utils/prompts/prompts.py @@ -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'로 통합됨. diff --git a/app/utils/prompts/schemas/__init__.py b/app/utils/prompts/schemas/__init__.py index 3729667..1498b61 100644 --- a/app/utils/prompts/schemas/__init__.py +++ b/app/utils/prompts/schemas/__init__.py @@ -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 diff --git a/app/utils/prompts/schemas/ssulbox.py b/app/utils/prompts/schemas/ssulbox.py new file mode 100644 index 0000000..3f3d623 --- /dev/null +++ b/app/utils/prompts/schemas/ssulbox.py @@ -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="태그 키워드 리스트") diff --git a/docs/prompts/ssul_upload.md b/docs/prompts/ssul_upload.md new file mode 100644 index 0000000..0d09d52 --- /dev/null +++ b/docs/prompts/ssul_upload.md @@ -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]` 라인을 본다.