From b3354d4ad1815b6d0883b084b8dd4971f05790bd Mon Sep 17 00:00:00 2001 From: jaehwang Date: Fri, 20 Feb 2026 01:11:57 +0000 Subject: [PATCH 1/4] =?UTF-8?q?=EC=9C=A0=ED=8A=9C=EB=B8=8C=20SEO=20?= =?UTF-8?q?=EC=84=A4=EB=AA=85=20=EC=B6=94=EA=B0=80=20PoC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/lyric/api/routers/v1/lyric.py | 1 - app/social/api/routers/v1/upload.py | 71 +++++++++++++++++-- app/social/schemas.py | 26 +++++++ app/utils/chatgpt_prompt.py | 2 +- app/utils/prompts/prompts.py | 10 ++- app/utils/prompts/schemas/__init__.py | 3 +- .../schemas/{youtube_desc.py => youtube.py} | 0 config.py | 2 + 8 files changed, 106 insertions(+), 9 deletions(-) rename app/utils/prompts/schemas/{youtube_desc.py => youtube.py} (100%) diff --git a/app/lyric/api/routers/v1/lyric.py b/app/lyric/api/routers/v1/lyric.py index c508723..50f6575 100644 --- a/app/lyric/api/routers/v1/lyric.py +++ b/app/lyric/api/routers/v1/lyric.py @@ -292,7 +292,6 @@ async def generate_lyric( "promotional_expression_example" : promotional_expressions[request_body.language], "timing_rules" : timing_rules["60s"], # 아직은 선택지 하나 } - estimated_prompt = lyric_prompt.build_prompt(lyric_input_data) step1_elapsed = (time.perf_counter() - step1_start) * 1000 #logger.debug(f"[generate_lyric] Step 1 완료 - 프롬프트 {len(prompt)}자 ({step1_elapsed:.1f}ms)") diff --git a/app/social/api/routers/v1/upload.py b/app/social/api/routers/v1/upload.py index f9a3889..9e5c1ea 100644 --- a/app/social/api/routers/v1/upload.py +++ b/app/social/api/routers/v1/upload.py @@ -4,10 +4,11 @@ 소셜 미디어 영상 업로드 관련 엔드포인트를 제공합니다. """ -import logging +import logging, json from typing import Optional from fastapi import APIRouter, BackgroundTasks, Depends, Query +from fastapi import HTTPException, status from sqlalchemy import select, func from sqlalchemy.ext.asyncio import AsyncSession @@ -22,12 +23,17 @@ from app.social.schemas import ( SocialUploadRequest, SocialUploadResponse, SocialUploadStatusResponse, + YoutubeDescriptionRequest, + YoutubeDescriptionResponse, ) from app.social.services import social_account_service from app.social.worker.upload_task import process_social_upload from app.user.dependencies import get_current_user from app.user.models import User +from app.home.models import Project, MarketingIntel from app.video.models import Video +from app.utils.prompts.prompts import yt_upload_prompt +from app.utils.chatgpt_prompt import ChatgptService, ChatGPTResponseError logger = logging.getLogger(__name__) @@ -403,16 +409,12 @@ async def cancel_upload( upload = result.scalar_one_or_none() if not upload: - from fastapi import HTTPException, status - raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="업로드 정보를 찾을 수 없습니다.", ) if upload.status != UploadStatus.PENDING.value: - from fastapi import HTTPException, status - raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="대기 중인 업로드만 취소할 수 있습니다.", @@ -425,3 +427,62 @@ async def cancel_upload( success=True, message="업로드가 취소되었습니다.", ) + + +@router.post( + "/autodescription", + response_model=YoutubeDescriptionResponse, + summary="유튜브 SEO descrption 생성", + description="유튜브 업로드 시 사용할 descrption을 SEO 적용하여 생성", +) +async def youtube_seo_description( + request_body: YoutubeDescriptionRequest, + current_user: User = Depends(get_current_user), + session: AsyncSession = Depends(get_session), + ) -> YoutubeDescriptionResponse: + + logger.info( + f"[youtube_seo_description] START - user: {current_user.user_uuid} " + ) + try: + task_id = request_body.task_id + project_query = await session.execute( + select(Project) + .where( + Project.task_id == task_id, + Project.user_uuid == current_user.user_uuid) + .order_by(Project.created_at.desc()) + .limit(1) + ) + + project = project_query.scalar_one_or_none() + marketing_query = await session.execute( + select(MarketingIntel) + .where(MarketingIntel.id == project.marketing_inteligence) + ) + marketing_intelligence = marketing_query.scalar_one_or_none() + + hashtags = marketing_intelligence.intel_result["target_keywords"] + + yt_seo_input_data = { + "customer_name" : project.store_name, + "detail_region_info" : project.detail_region_info, + "marketing_intelligence_summary" : json.dumps(marketing_intelligence.intel_result, ensure_ascii=False), + "language" : project.language, + "target_keywords" : hashtags + } + chatgpt = ChatgptService() + yt_seo_output = await chatgpt.generate_structured_output(yt_upload_prompt, yt_seo_input_data) + response = YoutubeDescriptionResponse( + title=yt_seo_output.title, + description=yt_seo_output.description, + keywords = hashtags + ) + return response + + except Exception as e: + logger.error(f"[youtube_seo_description] EXCEPTION - error: {e}") + raise HTTPException( + status_code=500, + detail=f"유튜브 SEO 생성에 실패했습니다. : {str(e)}", + ) diff --git a/app/social/schemas.py b/app/social/schemas.py index 2a3a1f6..580a1cb 100644 --- a/app/social/schemas.py +++ b/app/social/schemas.py @@ -276,6 +276,32 @@ class SocialUploadHistoryResponse(BaseModel): } ) +class YoutubeDescriptionRequest(BaseModel): + """유튜브 SEO Description 제안 (자동완성) Request 모델""" + + model_config = ConfigDict( + json_schema_extra={ + "example": { + "task_id" : "019c739f-65fc-7d15-8c88-b31be00e588e" + } + } + ) + task_id: str = Field(..., description="작업 고유 식별자") + +class YoutubeDescriptionResponse(BaseModel): + """유튜브 SEO Description 제안 (자동완성) Response 모델""" + title:str = Field(..., description="유튜브 영상 제목 - SEO/AEO 최적화") + description : str = Field(..., description="제안된 유튜브 SEO Description") + keywords : list[str] = Field(..., description="해시태그 리스트") + model_config = ConfigDict( + json_schema_extra={ + "example": { + "title" : "여기에 더미 타이틀", + "description": "여기에 더미 텍스트", + "keywords": ["여기에", "더미", "해시태그"] + } + } + ) # ============================================================================= # 공통 응답 스키마 diff --git a/app/utils/chatgpt_prompt.py b/app/utils/chatgpt_prompt.py index 0f1ede4..4b9a950 100644 --- a/app/utils/chatgpt_prompt.py +++ b/app/utils/chatgpt_prompt.py @@ -82,7 +82,7 @@ class ChatgptService: self, prompt : Prompt, input_data : dict, - ) -> str: + ) -> BaseModel: prompt_text = prompt.build_prompt(input_data) logger.debug(f"[ChatgptService] Generated Prompt (length: {len(prompt_text)})") diff --git a/app/utils/prompts/prompts.py b/app/utils/prompts/prompts.py index d9f5020..336318b 100644 --- a/app/utils/prompts/prompts.py +++ b/app/utils/prompts/prompts.py @@ -52,6 +52,14 @@ lyric_prompt = Prompt( prompt_model = prompt_settings.LYRIC_PROMPT_MODEL ) +yt_upload_prompt = Prompt( + prompt_template_path=os.path.join(prompt_settings.PROMPT_FOLDER_ROOT, prompt_settings.YOUTUBE_PROMPT_FILE_NAME), + prompt_input_class = YTUploadPromptInput, + prompt_output_class = YTUploadPromptOutput, + prompt_model = prompt_settings.YOUTUBE_PROMPT_MODEL +) + def reload_all_prompt(): marketing_prompt._reload_prompt() - lyric_prompt._reload_prompt() \ No newline at end of file + lyric_prompt._reload_prompt() + yt_upload_prompt._reload_prompt() \ No newline at end of file diff --git a/app/utils/prompts/schemas/__init__.py b/app/utils/prompts/schemas/__init__.py index 4080837..8cd267f 100644 --- a/app/utils/prompts/schemas/__init__.py +++ b/app/utils/prompts/schemas/__init__.py @@ -1,2 +1,3 @@ from .lyric import LyricPromptInput, LyricPromptOutput -from .marketing import MarketingPromptInput, MarketingPromptOutput \ No newline at end of file +from .marketing import MarketingPromptInput, MarketingPromptOutput +from .youtube import YTUploadPromptInput, YTUploadPromptOutput \ No newline at end of file diff --git a/app/utils/prompts/schemas/youtube_desc.py b/app/utils/prompts/schemas/youtube.py similarity index 100% rename from app/utils/prompts/schemas/youtube_desc.py rename to app/utils/prompts/schemas/youtube.py diff --git a/config.py b/config.py index 94c7c7c..3087ce9 100644 --- a/config.py +++ b/config.py @@ -184,6 +184,8 @@ class PromptSettings(BaseSettings): LYRIC_PROMPT_FILE_NAME : str = Field(default="lyric_prompt.txt") LYRIC_PROMPT_MODEL : str = Field(default="gpt-5-mini") + YOUTUBE_PROMPT_FILE_NAME : str = Field(default="yt_upload_prompt.txt") + YOUTUBE_PROMPT_MODEL : str = Field(default="gpt-5-mini") model_config = _base_config From 172586e6996d4b85f8809dced27c8aa9029d2090 Mon Sep 17 00:00:00 2001 From: jaehwang Date: Fri, 20 Feb 2026 08:19:45 +0000 Subject: [PATCH 2/4] =?UTF-8?q?=EC=9C=A0=ED=8A=9C=EB=B8=8C=20=EC=9E=90?= =?UTF-8?q?=EB=8F=99=20seo=20description=20redis=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/database/session.py | 2 + app/social/api/routers/v1/__init__.py | 4 +- app/social/api/routers/v1/seo.py | 131 ++++++++++++++++++++++++++ app/social/api/routers/v1/upload.py | 66 +------------ app/social/constants.py | 2 + config.py | 2 +- main.py | 2 + 7 files changed, 141 insertions(+), 68 deletions(-) create mode 100644 app/social/api/routers/v1/seo.py diff --git a/app/database/session.py b/app/database/session.py index 1412667..40be48b 100644 --- a/app/database/session.py +++ b/app/database/session.py @@ -126,7 +126,9 @@ async def get_session() -> AsyncGenerator[AsyncSession, None]: try: yield session except Exception as e: + import traceback await session.rollback() + logger.error(traceback.format_exc()) logger.error( f"[get_session] ROLLBACK - error: {type(e).__name__}: {e}, " f"duration: {(time.perf_counter() - start_time)*1000:.1f}ms" diff --git a/app/social/api/routers/v1/__init__.py b/app/social/api/routers/v1/__init__.py index c960715..736fa09 100644 --- a/app/social/api/routers/v1/__init__.py +++ b/app/social/api/routers/v1/__init__.py @@ -4,5 +4,5 @@ Social API Routers v1 from app.social.api.routers.v1.oauth import router as oauth_router from app.social.api.routers.v1.upload import router as upload_router - -__all__ = ["oauth_router", "upload_router"] +from app.social.api.routers.v1.seo import router as seo_router +__all__ = ["oauth_router", "upload_router", "seo_router"] diff --git a/app/social/api/routers/v1/seo.py b/app/social/api/routers/v1/seo.py new file mode 100644 index 0000000..6809c81 --- /dev/null +++ b/app/social/api/routers/v1/seo.py @@ -0,0 +1,131 @@ + +import logging, json + +from redis.asyncio import Redis + +from config import social_oauth_settings, db_settings +from app.social.constants import YOUTUBE_SEO_HASH +from sqlalchemy import select, func +from sqlalchemy.ext.asyncio import AsyncSession +from app.social.schemas import ( + YoutubeDescriptionRequest, + YoutubeDescriptionResponse, +) + +from app.database.session import get_session +from app.user.dependencies import get_current_user +from app.user.models import User +from app.home.models import Project, MarketingIntel +from fastapi import APIRouter, BackgroundTasks, Depends, Query +from fastapi import HTTPException, status +from app.utils.prompts.prompts import yt_upload_prompt +from app.utils.chatgpt_prompt import ChatgptService, ChatGPTResponseError + +redis_seo_client = Redis( + host=db_settings.REDIS_HOST, + port=db_settings.REDIS_PORT, + db=0, + decode_responses=True, +) + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/autodescription", tags=["Social SEO"]) + +@router.post( + "", + response_model=YoutubeDescriptionResponse, + summary="유튜브 SEO descrption 생성", + description="유튜브 업로드 시 사용할 descrption을 SEO 적용하여 생성", +) +async def youtube_seo_description( + request_body: YoutubeDescriptionRequest, + current_user: User = Depends(get_current_user), + session: AsyncSession = Depends(get_session), + ) -> YoutubeDescriptionResponse: + + # TODO : 나중에 Session Task_id 검증 미들웨어 만들면 추가해주세요. + + logger.info( + f"[youtube_seo_description] Try Cache - user: {current_user.user_uuid} / task_id : {request_body.task_id}" + ) + cached = await get_yt_seo_in_redis(request_body.task_id) + if cached: # redis hit + return cached + + logger.info( + f"[youtube_seo_description] Cache miss - user: {current_user.user_uuid} " + ) + updated_seo = await make_youtube_seo_description(request_body.task_id, current_user, session) + await set_yt_seo_in_redis(request_body.task_id, updated_seo) + + return updated_seo + +async def make_youtube_seo_description( + task_id: str, + current_user: User, + session: AsyncSession, + ) -> YoutubeDescriptionResponse: + + logger.info( + f"[make_youtube_seo_description] START - user: {current_user.user_uuid} " + ) + try: + project_query = await session.execute( + select(Project) + .where( + Project.task_id == task_id, + Project.user_uuid == current_user.user_uuid) + .order_by(Project.created_at.desc()) + .limit(1) + ) + + project = project_query.scalar_one_or_none() + marketing_query = await session.execute( + select(MarketingIntel) + .where(MarketingIntel.id == project.marketing_inteligence) + ) + marketing_intelligence = marketing_query.scalar_one_or_none() + + hashtags = marketing_intelligence.intel_result["target_keywords"] + + yt_seo_input_data = { + "customer_name" : project.store_name, + "detail_region_info" : project.detail_region_info, + "marketing_intelligence_summary" : json.dumps(marketing_intelligence.intel_result, ensure_ascii=False), + "language" : project.language, + "target_keywords" : hashtags + } + chatgpt = ChatgptService() + yt_seo_output = await chatgpt.generate_structured_output(yt_upload_prompt, yt_seo_input_data) + result_dict = { + "title" : yt_seo_output.title, + "description" : yt_seo_output.description, + "keywords": hashtags + } + + result = YoutubeDescriptionResponse(**result_dict) + return result + + except Exception as e: + logger.error(f"[youtube_seo_description] EXCEPTION - error: {e}") + raise HTTPException( + status_code=500, + detail=f"유튜브 SEO 생성에 실패했습니다. : {str(e)}", + ) + + +async def get_yt_seo_in_redis(task_id:str) -> YoutubeDescriptionResponse | None: + field = f"task_id:{task_id}" + yt_seo_info = await redis_seo_client.hget(YOUTUBE_SEO_HASH, field) + if yt_seo_info: + yt_seo = json.loads(yt_seo_info) + else: + return None + return YoutubeDescriptionResponse(**yt_seo) + +async def set_yt_seo_in_redis(task_id:str, yt_seo : YoutubeDescriptionResponse) -> None: + field = f"task_id:{task_id}" + yt_seo_info = json.dumps(yt_seo.model_dump(), ensure_ascii=False) + await redis_seo_client.hsetex(YOUTUBE_SEO_HASH, field, yt_seo_info, ex=3600) + return + diff --git a/app/social/api/routers/v1/upload.py b/app/social/api/routers/v1/upload.py index 9e5c1ea..781d19b 100644 --- a/app/social/api/routers/v1/upload.py +++ b/app/social/api/routers/v1/upload.py @@ -23,17 +23,12 @@ from app.social.schemas import ( SocialUploadRequest, SocialUploadResponse, SocialUploadStatusResponse, - YoutubeDescriptionRequest, - YoutubeDescriptionResponse, ) from app.social.services import social_account_service from app.social.worker.upload_task import process_social_upload from app.user.dependencies import get_current_user from app.user.models import User -from app.home.models import Project, MarketingIntel from app.video.models import Video -from app.utils.prompts.prompts import yt_upload_prompt -from app.utils.chatgpt_prompt import ChatgptService, ChatGPTResponseError logger = logging.getLogger(__name__) @@ -426,63 +421,4 @@ async def cancel_upload( return MessageResponse( success=True, message="업로드가 취소되었습니다.", - ) - - -@router.post( - "/autodescription", - response_model=YoutubeDescriptionResponse, - summary="유튜브 SEO descrption 생성", - description="유튜브 업로드 시 사용할 descrption을 SEO 적용하여 생성", -) -async def youtube_seo_description( - request_body: YoutubeDescriptionRequest, - current_user: User = Depends(get_current_user), - session: AsyncSession = Depends(get_session), - ) -> YoutubeDescriptionResponse: - - logger.info( - f"[youtube_seo_description] START - user: {current_user.user_uuid} " - ) - try: - task_id = request_body.task_id - project_query = await session.execute( - select(Project) - .where( - Project.task_id == task_id, - Project.user_uuid == current_user.user_uuid) - .order_by(Project.created_at.desc()) - .limit(1) - ) - - project = project_query.scalar_one_or_none() - marketing_query = await session.execute( - select(MarketingIntel) - .where(MarketingIntel.id == project.marketing_inteligence) - ) - marketing_intelligence = marketing_query.scalar_one_or_none() - - hashtags = marketing_intelligence.intel_result["target_keywords"] - - yt_seo_input_data = { - "customer_name" : project.store_name, - "detail_region_info" : project.detail_region_info, - "marketing_intelligence_summary" : json.dumps(marketing_intelligence.intel_result, ensure_ascii=False), - "language" : project.language, - "target_keywords" : hashtags - } - chatgpt = ChatgptService() - yt_seo_output = await chatgpt.generate_structured_output(yt_upload_prompt, yt_seo_input_data) - response = YoutubeDescriptionResponse( - title=yt_seo_output.title, - description=yt_seo_output.description, - keywords = hashtags - ) - return response - - except Exception as e: - logger.error(f"[youtube_seo_description] EXCEPTION - error: {e}") - raise HTTPException( - status_code=500, - detail=f"유튜브 SEO 생성에 실패했습니다. : {str(e)}", - ) + ) \ No newline at end of file diff --git a/app/social/constants.py b/app/social/constants.py index 2899c7e..031e6db 100644 --- a/app/social/constants.py +++ b/app/social/constants.py @@ -94,6 +94,8 @@ YOUTUBE_SCOPES = [ "https://www.googleapis.com/auth/userinfo.profile", # 사용자 프로필 ] +YOUTUBE_SEO_HASH = "SEO_Describtion_YT" + # ============================================================================= # Instagram/Facebook OAuth Scopes (추후 구현) # ============================================================================= diff --git a/config.py b/config.py index 3087ce9..8aa00b5 100644 --- a/config.py +++ b/config.py @@ -600,9 +600,9 @@ class SocialUploadSettings(BaseSettings): prj_settings = ProjectSettings() +cors_settings = CORSSettings() apikey_settings = APIKeySettings() db_settings = DatabaseSettings() -cors_settings = CORSSettings() crawler_settings = CrawlerSettings() naver_api_settings = NaverAPISettings() azure_blob_settings = AzureBlobSettings() diff --git a/main.py b/main.py index db7b88d..453b6df 100644 --- a/main.py +++ b/main.py @@ -21,6 +21,7 @@ from app.sns.api.routers.v1.sns import router as sns_router from app.video.api.routers.v1.video import router as video_router from app.social.api.routers.v1.oauth import router as social_oauth_router from app.social.api.routers.v1.upload import router as social_upload_router +from app.social.api.routers.v1.seo import router as social_seo_router from app.utils.cors import CustomCORSMiddleware from config import prj_settings @@ -360,6 +361,7 @@ app.include_router(video_router) app.include_router(archive_router) # Archive API 라우터 추가 app.include_router(social_oauth_router, prefix="/social") # Social OAuth 라우터 추가 app.include_router(social_upload_router, prefix="/social") # Social Upload 라우터 추가 +app.include_router(social_seo_router, prefix="/social") # Social Upload 라우터 추가 app.include_router(sns_router) # SNS API 라우터 추가 # DEBUG 모드에서만 테스트 라우터 등록 From a4db70c2e633c0ae1cb806257c288a92aac333da Mon Sep 17 00:00:00 2001 From: jaehwang Date: Fri, 20 Feb 2026 08:25:11 +0000 Subject: [PATCH 3/4] =?UTF-8?q?seo=20=EA=B2=BD=EB=A1=9C=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/social/api/routers/v1/seo.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/social/api/routers/v1/seo.py b/app/social/api/routers/v1/seo.py index 6809c81..7d44d11 100644 --- a/app/social/api/routers/v1/seo.py +++ b/app/social/api/routers/v1/seo.py @@ -29,10 +29,10 @@ redis_seo_client = Redis( ) logger = logging.getLogger(__name__) -router = APIRouter(prefix="/autodescription", tags=["Social SEO"]) +router = APIRouter(prefix="/seo", tags=["Social SEO"]) @router.post( - "", + "/youtube", response_model=YoutubeDescriptionResponse, summary="유튜브 SEO descrption 생성", description="유튜브 업로드 시 사용할 descrption을 SEO 적용하여 생성", From 219e7ed7c09cd28c9b8692b2c88ae38858ced240 Mon Sep 17 00:00:00 2001 From: jaehwang Date: Mon, 23 Feb 2026 00:55:35 +0000 Subject: [PATCH 4/4] =?UTF-8?q?=EC=98=A4=ED=83=88=EC=9E=90=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/social/api/routers/v1/seo.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/social/api/routers/v1/seo.py b/app/social/api/routers/v1/seo.py index 7d44d11..af80407 100644 --- a/app/social/api/routers/v1/seo.py +++ b/app/social/api/routers/v1/seo.py @@ -82,7 +82,7 @@ async def make_youtube_seo_description( project = project_query.scalar_one_or_none() marketing_query = await session.execute( select(MarketingIntel) - .where(MarketingIntel.id == project.marketing_inteligence) + .where(MarketingIntel.id == project.marketing_intelligence) ) marketing_intelligence = marketing_query.scalar_one_or_none()