o2o-castad-backend/app/lyric/api/routers/v1/lyric.py

538 lines
20 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

"""
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" : """
812 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_intelligence = 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)