""" Lyric API Router 이 모듈은 가사 관련 API 엔드포인트를 정의합니다. 모든 엔드포인트는 재사용 가능하도록 설계되었습니다. 엔드포인트 목록: - POST /lyric/generate: 가사 생성 - GET /lyric/status/{task_id}: 가사 생성 상태 조회 - GET /lyric/{task_id}: 가사 상세 조회 - GET /lyric/list: 가사 목록 조회 (페이지네이션) 사용 예시: from app.lyric.api.routers.v1.lyric import router app.include_router(router) 다른 서비스에서 재사용: # 이 파일의 헬퍼 함수들을 import하여 사용 가능 from app.lyric.api.routers.v1.lyric import ( get_lyric_status_by_task_id, get_lyric_by_task_id, ) # 페이지네이션은 pagination 모듈 사용 from app.utils.pagination import PaginatedResponse, get_paginated """ from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, status from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.database.session import get_session from app.home.models import Project, MarketingIntel from app.user.dependencies.auth import get_current_user from app.user.models import User from app.lyric.models import Lyric from app.lyric.schemas.lyric import ( GenerateLyricRequest, GenerateLyricResponse, LyricDetailResponse, LyricListItem, LyricStatusResponse, ) from app.lyric.worker.lyric_task import generate_lyric_background from app.utils.chatgpt_prompt import ChatgptService from app.utils.logger import get_logger from app.utils.pagination import PaginatedResponse, get_paginated from app.utils.prompts.prompts import lyric_prompt import traceback as tb import json # 로거 설정 logger = get_logger("lyric") router = APIRouter(prefix="/lyric", tags=["Lyric"]) # ============================================================================= # Reusable Service Functions (다른 모듈에서 import하여 사용 가능) # ============================================================================= async def get_lyric_status_by_task_id( session: AsyncSession, task_id: str ) -> LyricStatusResponse: """task_id로 가사 생성 작업의 상태를 조회합니다. Args: session: SQLAlchemy AsyncSession task_id: 작업 고유 식별자 Returns: LyricStatusResponse: 상태 정보 Raises: HTTPException: 404 - task_id에 해당하는 가사가 없는 경우 Usage: # 다른 서비스에서 사용 from app.lyric.api.routers.v1.lyric import get_lyric_status_by_task_id status_info = await get_lyric_status_by_task_id(session, "some-task-id") if status_info.status == "completed": # 완료 처리 """ logger.info(f"[get_lyric_status_by_task_id] START - task_id: {task_id}") result = await session.execute( select(Lyric) .where(Lyric.task_id == task_id) .order_by(Lyric.created_at.desc()) .limit(1) ) lyric = result.scalar_one_or_none() if not lyric: logger.warning(f"[get_lyric_status_by_task_id] NOT FOUND - task_id: {task_id}") raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"task_id '{task_id}'에 해당하는 가사를 찾을 수 없습니다.", ) status_messages = { "processing": "가사 생성 중입니다.", "completed": "가사 생성이 완료되었습니다.", "failed": "가사 생성에 실패했습니다.", } logger.info( f"[get_lyric_status_by_task_id] SUCCESS - task_id: {task_id}, status: {lyric.status}" ) return LyricStatusResponse( task_id=lyric.task_id, status=lyric.status, message=status_messages.get(lyric.status, "알 수 없는 상태입니다."), ) async def get_lyric_by_task_id( session: AsyncSession, task_id: str ) -> LyricDetailResponse: """task_id로 생성된 가사 상세 정보를 조회합니다. Args: session: SQLAlchemy AsyncSession task_id: 작업 고유 식별자 Returns: LyricDetailResponse: 가사 상세 정보 Raises: HTTPException: 404 - task_id에 해당하는 가사가 없는 경우 Usage: # 다른 서비스에서 사용 from app.lyric.api.routers.v1.lyric import get_lyric_by_task_id lyric = await get_lyric_by_task_id(session, task_id) """ logger.info(f"[get_lyric_by_task_id] START - task_id: {task_id}") result = await session.execute( select(Lyric) .where(Lyric.task_id == task_id) .order_by(Lyric.created_at.desc()) .limit(1) ) lyric = result.scalar_one_or_none() if not lyric: logger.warning(f"[get_lyric_by_task_id] NOT FOUND - task_id: {task_id}") raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"task_id '{task_id}'에 해당하는 가사를 찾을 수 없습니다.", ) logger.info(f"[get_lyric_by_task_id] SUCCESS - task_id: {task_id}, lyric_id: {lyric.id}") return LyricDetailResponse( id=lyric.id, task_id=lyric.task_id, project_id=lyric.project_id, status=lyric.status, lyric_result=lyric.lyric_result, created_at=lyric.created_at, ) # ============================================================================= # API Endpoints # ============================================================================= @router.post( "/generate", summary="가사 생성", description=""" 고객 정보를 기반으로 ChatGPT를 이용하여 가사를 생성합니다. 백그라운드에서 비동기로 처리되며, 즉시 task_id를 반환합니다. ## 인증 **Bearer 토큰 필수** - `Authorization: Bearer {access_token}` 헤더를 포함해야 합니다. ## 요청 필드 - **task_id**: 작업 고유 식별자 (이미지 업로드 시 생성된 task_id, 필수) - **customer_name**: 고객명/가게명 (필수) - **region**: 지역명 (필수) - **detail_region_info**: 상세 지역 정보 (선택) - **language**: 가사 출력 언어 (Korean, English, Chinese, Japanese, Thai, Vietnamese) ## 반환 정보 - **success**: 요청 접수 성공 여부 - **task_id**: 작업 고유 식별자 - **lyric**: null (백그라운드 처리 중) - **language**: 가사 언어 - **error_message**: 에러 메시지 (요청 접수 실패 시) ## 상태 확인 - GET /lyric/status/{task_id} 로 처리 상태 확인 - GET /lyric/{task_id} 로 생성된 가사 조회 ## 사용 예시 (cURL) ```bash curl -X POST "http://localhost:8000/lyric/generate" \\ -H "Authorization: Bearer {access_token}" \\ -H "Content-Type: application/json" \\ -d '{ "task_id": "0694b716-dbff-7219-8000-d08cb5fce431", "customer_name": "스테이 머뭄", "region": "군산", "detail_region_info": "군산 신흥동 말랭이 마을", "language": "Korean" }' ``` ## 응답 예시 ```json { "success": true, "task_id": "0694b716-dbff-7219-8000-d08cb5fce431", "lyric": null, "language": "Korean", "error_message": null } ``` """, response_model=GenerateLyricResponse, responses={ 200: {"description": "가사 생성 요청 접수 성공"}, 401: {"description": "인증 실패 (토큰 없음/만료)"}, 500: {"description": "서버 내부 오류"}, }, ) async def generate_lyric( request_body: GenerateLyricRequest, background_tasks: BackgroundTasks, current_user: User = Depends(get_current_user), session: AsyncSession = Depends(get_session), ) -> GenerateLyricResponse: """고객 정보를 기반으로 가사를 생성합니다. (백그라운드 처리)""" import time request_start = time.perf_counter() task_id = request_body.task_id logger.info(f"[generate_lyric] ========== START ==========") logger.info( f"[generate_lyric] task_id: {task_id}, " f"customer_name: {request_body.customer_name}, " f"region: {request_body.region}" ) try: # ========== Step 1: ChatGPT 서비스 초기화 및 프롬프트 생성 ========== step1_start = time.perf_counter() logger.debug(f"[generate_lyric] Step 1: 서비스 초기화 및 프롬프트 생성...") # service = ChatgptService( # customer_name=request_body.customer_name, # region=request_body.region, # detail_region_info=request_body.detail_region_info or "", # language=request_body.language, # ) # prompt = service.build_lyrics_prompt() # 원래는 실제 사용할 프롬프트가 들어가야 하나, 로직이 변경되어 이 시점에서 이곳에서 프롬프트를 생성할 이유가 없어서 삭제됨. # 기존 코드와의 호환을 위해 동일한 로직으로 프롬프트 생성 promotional_expressions = { "Korean" : "인스타 감성, 사진같은 하루, 힐링, 여행, 감성 숙소", "English" : "Instagram vibes, picture-perfect day, healing, travel, getaway", "Chinese" : "网红打卡, 治愈系, 旅行, 度假, 拍照圣地", "Japanese" : "インスタ映え, 写真のような一日, 癒し, 旅行, 絶景", "Thai" : "ที่พักสวย, ฮีลใจ, เที่ยว, ถ่ายรูป, วิวสวย", "Vietnamese" : "check-in đẹp, healing, du lịch, nghỉ dưỡng, view đẹp" }# HARD CODED, 어디에 정리하지? 아직 정리되지 않음 timing_rules = { "60s" : """ 8–12 lines Full verse flow, immersive mood """ } marketing_intel_result = await session.execute(select(MarketingIntel).where(MarketingIntel.id == request_body.m_id)) marketing_intel = marketing_intel_result.scalar_one_or_none() lyric_input_data = { "customer_name" : request_body.customer_name, "region" : request_body.region, "detail_region_info" : request_body.detail_region_info or "", "marketing_intelligence_summary" : json.dumps(marketing_intel.intel_result, ensure_ascii = False), "language" : request_body.language, "promotional_expression_example" : promotional_expressions[request_body.language], "timing_rules" : timing_rules["60s"], # 아직은 선택지 하나 } step1_elapsed = (time.perf_counter() - step1_start) * 1000 #logger.debug(f"[generate_lyric] Step 1 완료 - 프롬프트 {len(prompt)}자 ({step1_elapsed:.1f}ms)") # ========== Step 2: Project 조회 또는 생성 ========== step2_start = time.perf_counter() logger.debug(f"[generate_lyric] Step 2: Project 조회 또는 생성...") # 기존 Project가 있는지 확인 (재생성 시 재사용) existing_project_result = await session.execute( select(Project).where(Project.task_id == task_id).limit(1) ) project = existing_project_result.scalar_one_or_none() if project: # 기존 Project 재사용 (재생성 케이스) logger.info(f"[generate_lyric] 기존 Project 재사용 - project_id: {project.id}, task_id: {task_id}") else: # 새 Project 생성 (최초 생성 케이스) project = Project( store_name=request_body.customer_name, region=request_body.region, task_id=task_id, detail_region_info=request_body.detail_region_info, language=request_body.language, user_uuid=current_user.user_uuid, marketing_inteligence = request_body.m_id ) session.add(project) await session.commit() await session.refresh(project) logger.info(f"[generate_lyric] 새 Project 생성 - project_id: {project.id}, task_id: {task_id}") step2_elapsed = (time.perf_counter() - step2_start) * 1000 logger.debug(f"[generate_lyric] Step 2 완료 - project_id: {project.id} ({step2_elapsed:.1f}ms)") # ========== Step 3: Lyric 테이블에 데이터 저장 ========== step3_start = time.perf_counter() logger.debug(f"[generate_lyric] Step 3: Lyric 저장 (processing)...") estimated_prompt = lyric_prompt.build_prompt(lyric_input_data) lyric = Lyric( project_id=project.id, task_id=task_id, status="processing", lyric_prompt=estimated_prompt, lyric_result=None, language=request_body.language, ) session.add(lyric) await session.commit() await session.refresh(lyric) step3_elapsed = (time.perf_counter() - step3_start) * 1000 logger.debug(f"[generate_lyric] Step 3 완료 - lyric_id: {lyric.id} ({step3_elapsed:.1f}ms)") # ========== Step 4: 백그라운드 태스크 스케줄링 ========== step4_start = time.perf_counter() logger.debug(f"[generate_lyric] Step 4: 백그라운드 태스크 스케줄링...") background_tasks.add_task( generate_lyric_background, task_id=task_id, prompt=lyric_prompt, lyric_input_data=lyric_input_data, lyric_id=lyric.id, ) step4_elapsed = (time.perf_counter() - step4_start) * 1000 logger.debug(f"[generate_lyric] Step 4 완료 ({step4_elapsed:.1f}ms)") # ========== 완료 ========== total_elapsed = (time.perf_counter() - request_start) * 1000 logger.info(f"[generate_lyric] ========== COMPLETE ==========") logger.info(f"[generate_lyric] API 응답 소요시간: {total_elapsed:.1f}ms") logger.debug(f"[generate_lyric] - Step 1 (프롬프트 생성): {step1_elapsed:.1f}ms") logger.debug(f"[generate_lyric] - Step 2 (Project 저장): {step2_elapsed:.1f}ms") logger.debug(f"[generate_lyric] - Step 3 (Lyric 저장): {step3_elapsed:.1f}ms") logger.debug(f"[generate_lyric] - Step 4 (태스크 스케줄링): {step4_elapsed:.1f}ms") logger.debug(f"[generate_lyric] (GPT API 호출은 백그라운드에서 별도 진행)") # 5. 즉시 응답 반환 return GenerateLyricResponse( success=True, task_id=task_id, lyric=None, language=request_body.language, error_message=None, ) except Exception as e: elapsed = (time.perf_counter() - request_start) * 1000 logger.error(f"[generate_lyric] EXCEPTION - task_id: {task_id}, error: {e} ({elapsed:.1f}ms)") await session.rollback() return GenerateLyricResponse( success=False, task_id=task_id, lyric=None, language=request_body.language, error_message=''.join(tb.format_exception(None, e, e.__traceback__)), ) @router.get( "/status/{task_id}", summary="가사 생성 상태 조회", description=""" task_id로 가사 생성 작업의 현재 상태를 조회합니다. ## 인증 **Bearer 토큰 필수** - `Authorization: Bearer {access_token}` 헤더를 포함해야 합니다. ## 상태 값 - **processing**: 가사 생성 중 - **completed**: 가사 생성 완료 - **failed**: 가사 생성 실패 ## 사용 예시 (cURL) ```bash curl -X GET "http://localhost:8000/lyric/status/019123ab-cdef-7890-abcd-ef1234567890" \\ -H "Authorization: Bearer {access_token}" ``` """, response_model=LyricStatusResponse, responses={ 200: {"description": "상태 조회 성공"}, 401: {"description": "인증 실패 (토큰 없음/만료)"}, 404: {"description": "해당 task_id를 찾을 수 없음"}, }, ) async def get_lyric_status( task_id: str, current_user: User = Depends(get_current_user), session: AsyncSession = Depends(get_session), ) -> LyricStatusResponse: """task_id로 가사 생성 작업 상태를 조회합니다.""" return await get_lyric_status_by_task_id(session, task_id) @router.get( "/list", summary="가사 목록 조회 (페이지네이션)", description=""" 생성 완료된 가사를 페이지네이션으로 조회합니다. ## 인증 **Bearer 토큰 필수** - `Authorization: Bearer {access_token}` 헤더를 포함해야 합니다. ## 파라미터 - **page**: 페이지 번호 (1부터 시작, 기본값: 1) - **page_size**: 페이지당 데이터 수 (기본값: 20, 최대: 100) ## 반환 정보 - **items**: 가사 목록 (completed 상태만) - **total**: 전체 데이터 수 - **page**: 현재 페이지 - **page_size**: 페이지당 데이터 수 - **total_pages**: 전체 페이지 수 - **has_next**: 다음 페이지 존재 여부 - **has_prev**: 이전 페이지 존재 여부 ## 사용 예시 (cURL) ```bash # 기본 조회 (1페이지, 20개) curl -X GET "http://localhost:8000/lyric/list" \\ -H "Authorization: Bearer {access_token}" # 2페이지 조회 curl -X GET "http://localhost:8000/lyric/list?page=2" \\ -H "Authorization: Bearer {access_token}" # 50개씩 조회 curl -X GET "http://localhost:8000/lyric/list?page=1&page_size=50" \\ -H "Authorization: Bearer {access_token}" ``` ## 참고 - 생성 완료(completed)된 가사만 조회됩니다. - processing, failed 상태의 가사는 조회되지 않습니다. """, response_model=PaginatedResponse[LyricListItem], responses={ 200: {"description": "가사 목록 조회 성공"}, 401: {"description": "인증 실패 (토큰 없음/만료)"}, }, ) async def list_lyrics( page: int = Query(1, ge=1, description="페이지 번호 (1부터 시작)"), page_size: int = Query(20, ge=1, le=100, description="페이지당 데이터 수"), current_user: User = Depends(get_current_user), session: AsyncSession = Depends(get_session), ) -> PaginatedResponse[LyricListItem]: """페이지네이션으로 완료된 가사 목록을 조회합니다.""" return await get_paginated( session=session, model=Lyric, item_schema=LyricListItem, page=page, page_size=page_size, filters={"status": "completed"}, order_by="created_at", order_desc=True, ) @router.get( "/{task_id}", summary="가사 상세 조회", description=""" task_id로 생성된 가사의 상세 정보를 조회합니다. ## 인증 **Bearer 토큰 필수** - `Authorization: Bearer {access_token}` 헤더를 포함해야 합니다. ## 반환 정보 - **id**: 가사 ID - **task_id**: 작업 고유 식별자 - **project_id**: 프로젝트 ID - **status**: 처리 상태 - **lyric_prompt**: 가사 생성에 사용된 프롬프트 - **lyric_result**: 생성된 가사 (완료 시) - **created_at**: 생성 일시 ## 사용 예시 (cURL) ```bash curl -X GET "http://localhost:8000/lyric/019123ab-cdef-7890-abcd-ef1234567890" \\ -H "Authorization: Bearer {access_token}" ``` """, response_model=LyricDetailResponse, responses={ 200: {"description": "가사 조회 성공"}, 401: {"description": "인증 실패 (토큰 없음/만료)"}, 404: {"description": "해당 task_id를 찾을 수 없음"}, }, ) async def get_lyric_detail( task_id: str, current_user: User = Depends(get_current_user), session: AsyncSession = Depends(get_session), ) -> LyricDetailResponse: """task_id로 생성된 가사를 조회합니다.""" return await get_lyric_by_task_id(session, task_id)