add logs for tracing processing task
|
|
@ -1,5 +1,16 @@
|
|||
import logging
|
||||
import traceback
|
||||
from functools import wraps
|
||||
from typing import Any, Callable, TypeVar
|
||||
|
||||
from fastapi import FastAPI, HTTPException, Request, Response, status
|
||||
from fastapi.responses import JSONResponse
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
|
||||
# 로거 설정
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
class FastShipError(Exception):
|
||||
|
|
@ -61,6 +72,194 @@ class DeliveryPartnerCapacityExceeded(FastShipError):
|
|||
status = status.HTTP_406_NOT_ACCEPTABLE
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 데이터베이스 관련 예외
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class DatabaseError(FastShipError):
|
||||
"""Database operation failed"""
|
||||
|
||||
status = status.HTTP_503_SERVICE_UNAVAILABLE
|
||||
|
||||
|
||||
class DatabaseConnectionError(DatabaseError):
|
||||
"""Database connection failed"""
|
||||
|
||||
status = status.HTTP_503_SERVICE_UNAVAILABLE
|
||||
|
||||
|
||||
class DatabaseTimeoutError(DatabaseError):
|
||||
"""Database operation timed out"""
|
||||
|
||||
status = status.HTTP_504_GATEWAY_TIMEOUT
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 외부 서비스 관련 예외
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class ExternalServiceError(FastShipError):
|
||||
"""External service call failed"""
|
||||
|
||||
status = status.HTTP_502_BAD_GATEWAY
|
||||
|
||||
|
||||
class GPTServiceError(ExternalServiceError):
|
||||
"""GPT API call failed"""
|
||||
|
||||
status = status.HTTP_502_BAD_GATEWAY
|
||||
|
||||
|
||||
class CrawlingError(ExternalServiceError):
|
||||
"""Web crawling failed"""
|
||||
|
||||
status = status.HTTP_502_BAD_GATEWAY
|
||||
|
||||
|
||||
class BlobStorageError(ExternalServiceError):
|
||||
"""Azure Blob Storage operation failed"""
|
||||
|
||||
status = status.HTTP_502_BAD_GATEWAY
|
||||
|
||||
|
||||
class CreatomateError(ExternalServiceError):
|
||||
"""Creatomate API call failed"""
|
||||
|
||||
status = status.HTTP_502_BAD_GATEWAY
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 예외 처리 데코레이터
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def handle_db_exceptions(
|
||||
error_message: str = "데이터베이스 작업 중 오류가 발생했습니다.",
|
||||
):
|
||||
"""데이터베이스 예외를 처리하는 데코레이터.
|
||||
|
||||
Args:
|
||||
error_message: 오류 발생 시 반환할 메시지
|
||||
|
||||
Example:
|
||||
@handle_db_exceptions("사용자 조회 중 오류 발생")
|
||||
async def get_user(user_id: int):
|
||||
...
|
||||
"""
|
||||
|
||||
def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
|
||||
@wraps(func)
|
||||
async def wrapper(*args: Any, **kwargs: Any) -> Any:
|
||||
try:
|
||||
return await func(*args, **kwargs)
|
||||
except HTTPException:
|
||||
# HTTPException은 그대로 raise
|
||||
raise
|
||||
except SQLAlchemyError as e:
|
||||
logger.error(f"[DB Error] {func.__name__}: {e}")
|
||||
logger.error(traceback.format_exc())
|
||||
print(f"[DB Error] {func.__name__}: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail=error_message,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"[Unexpected Error] {func.__name__}: {e}")
|
||||
logger.error(traceback.format_exc())
|
||||
print(f"[Unexpected Error] {func.__name__}: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="서비스 처리 중 예기치 않은 오류가 발생했습니다.",
|
||||
)
|
||||
|
||||
return wrapper
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def handle_external_service_exceptions(
|
||||
service_name: str = "외부 서비스",
|
||||
error_message: str | None = None,
|
||||
):
|
||||
"""외부 서비스 호출 예외를 처리하는 데코레이터.
|
||||
|
||||
Args:
|
||||
service_name: 서비스 이름 (로그용)
|
||||
error_message: 오류 발생 시 반환할 메시지
|
||||
|
||||
Example:
|
||||
@handle_external_service_exceptions("GPT")
|
||||
async def call_gpt():
|
||||
...
|
||||
"""
|
||||
|
||||
def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
|
||||
@wraps(func)
|
||||
async def wrapper(*args: Any, **kwargs: Any) -> Any:
|
||||
try:
|
||||
return await func(*args, **kwargs)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
msg = error_message or f"{service_name} 서비스 호출 중 오류가 발생했습니다."
|
||||
logger.error(f"[{service_name} Error] {func.__name__}: {e}")
|
||||
logger.error(traceback.format_exc())
|
||||
print(f"[{service_name} Error] {func.__name__}: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail=msg,
|
||||
)
|
||||
|
||||
return wrapper
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def handle_api_exceptions(
|
||||
error_message: str = "요청 처리 중 오류가 발생했습니다.",
|
||||
):
|
||||
"""API 엔드포인트 예외를 처리하는 데코레이터.
|
||||
|
||||
Args:
|
||||
error_message: 오류 발생 시 반환할 메시지
|
||||
|
||||
Example:
|
||||
@handle_api_exceptions("가사 생성 중 오류 발생")
|
||||
async def generate_lyric():
|
||||
...
|
||||
"""
|
||||
|
||||
def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
|
||||
@wraps(func)
|
||||
async def wrapper(*args: Any, **kwargs: Any) -> Any:
|
||||
try:
|
||||
return await func(*args, **kwargs)
|
||||
except HTTPException:
|
||||
raise
|
||||
except SQLAlchemyError as e:
|
||||
logger.error(f"[API DB Error] {func.__name__}: {e}")
|
||||
logger.error(traceback.format_exc())
|
||||
print(f"[API DB Error] {func.__name__}: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="데이터베이스 연결에 문제가 발생했습니다.",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"[API Error] {func.__name__}: {e}")
|
||||
logger.error(traceback.format_exc())
|
||||
print(f"[API Error] {func.__name__}: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=error_message,
|
||||
)
|
||||
|
||||
return wrapper
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def _get_handler(status: int, detail: str):
|
||||
# Define
|
||||
def handler(request: Request, exception: Exception) -> Response:
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
import json
|
||||
import logging
|
||||
import traceback
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
|
@ -6,6 +8,7 @@ from urllib.parse import unquote, urlparse
|
|||
|
||||
import aiofiles
|
||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.session import get_session, AsyncSessionLocal
|
||||
|
|
@ -23,7 +26,10 @@ from app.home.schemas.home_schema import (
|
|||
from app.utils.upload_blob_as_request import AzureBlobUploader
|
||||
from app.utils.chatgpt_prompt import ChatgptService
|
||||
from app.utils.common import generate_task_id
|
||||
from app.utils.nvMapScraper import NvMapScraper
|
||||
from app.utils.nvMapScraper import NvMapScraper, GraphQLException
|
||||
|
||||
# 로거 설정
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
MEDIA_ROOT = Path("media")
|
||||
|
||||
|
|
@ -91,15 +97,56 @@ def _extract_region_from_address(road_address: str | None) -> str:
|
|||
"description": "잘못된 URL",
|
||||
"model": ErrorResponse,
|
||||
},
|
||||
502: {
|
||||
"description": "크롤링 실패",
|
||||
"model": ErrorResponse,
|
||||
},
|
||||
},
|
||||
tags=["crawling"],
|
||||
)
|
||||
async def crawling(request_body: CrawlingRequest):
|
||||
"""네이버 지도 장소 크롤링"""
|
||||
import time
|
||||
|
||||
request_start = time.perf_counter()
|
||||
logger.info(f"[crawling] START - url: {request_body.url[:80]}...")
|
||||
print(f"[crawling] ========== START ==========")
|
||||
print(f"[crawling] URL: {request_body.url[:80]}...")
|
||||
|
||||
# ========== Step 1: 네이버 지도 크롤링 ==========
|
||||
step1_start = time.perf_counter()
|
||||
print(f"[crawling] Step 1: 네이버 지도 크롤링 시작...")
|
||||
|
||||
try:
|
||||
scraper = NvMapScraper(request_body.url)
|
||||
await scraper.scrap()
|
||||
except GraphQLException as e:
|
||||
step1_elapsed = (time.perf_counter() - step1_start) * 1000
|
||||
logger.error(f"[crawling] Step 1 FAILED - GraphQL 크롤링 실패: {e} ({step1_elapsed:.1f}ms)")
|
||||
print(f"[crawling] Step 1 FAILED - {e} ({step1_elapsed:.1f}ms)")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail=f"네이버 지도 크롤링에 실패했습니다: {e}",
|
||||
)
|
||||
except Exception as e:
|
||||
step1_elapsed = (time.perf_counter() - step1_start) * 1000
|
||||
logger.error(f"[crawling] Step 1 FAILED - 크롤링 중 예기치 않은 오류: {e} ({step1_elapsed:.1f}ms)")
|
||||
print(f"[crawling] Step 1 FAILED - {e} ({step1_elapsed:.1f}ms)")
|
||||
traceback.print_exc()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail="네이버 지도 크롤링 중 오류가 발생했습니다.",
|
||||
)
|
||||
|
||||
step1_elapsed = (time.perf_counter() - step1_start) * 1000
|
||||
image_count = len(scraper.image_link_list) if scraper.image_link_list else 0
|
||||
logger.info(f"[crawling] Step 1 완료 - 이미지 {image_count}개 ({step1_elapsed:.1f}ms)")
|
||||
print(f"[crawling] Step 1 완료 - 이미지 {image_count}개 ({step1_elapsed:.1f}ms)")
|
||||
|
||||
# ========== Step 2: 정보 가공 ==========
|
||||
step2_start = time.perf_counter()
|
||||
print(f"[crawling] Step 2: 정보 가공 시작...")
|
||||
|
||||
# 가공된 정보 생성
|
||||
processed_info = None
|
||||
marketing_analysis = None
|
||||
|
||||
|
|
@ -114,16 +161,73 @@ async def crawling(request_body: CrawlingRequest):
|
|||
detail_region_info=road_address or "",
|
||||
)
|
||||
|
||||
# ChatGPT를 이용한 마케팅 분석
|
||||
step2_elapsed = (time.perf_counter() - step2_start) * 1000
|
||||
logger.info(f"[crawling] Step 2 완료 - {customer_name}, {region} ({step2_elapsed:.1f}ms)")
|
||||
print(f"[crawling] Step 2 완료 - {customer_name}, {region} ({step2_elapsed:.1f}ms)")
|
||||
|
||||
# ========== Step 3: ChatGPT 마케팅 분석 ==========
|
||||
step3_start = time.perf_counter()
|
||||
print(f"[crawling] Step 3: ChatGPT 마케팅 분석 시작...")
|
||||
|
||||
try:
|
||||
# Step 3-1: ChatGPT 서비스 초기화
|
||||
step3_1_start = time.perf_counter()
|
||||
chatgpt_service = ChatgptService(
|
||||
customer_name=customer_name,
|
||||
region=region,
|
||||
detail_region_info=road_address or "",
|
||||
)
|
||||
step3_1_elapsed = (time.perf_counter() - step3_1_start) * 1000
|
||||
print(f"[crawling] Step 3-1: 서비스 초기화 완료 ({step3_1_elapsed:.1f}ms)")
|
||||
|
||||
# Step 3-2: 프롬프트 생성
|
||||
step3_2_start = time.perf_counter()
|
||||
prompt = chatgpt_service.build_market_analysis_prompt()
|
||||
step3_2_elapsed = (time.perf_counter() - step3_2_start) * 1000
|
||||
print(f"[crawling] Step 3-2: 프롬프트 생성 완료 - {len(prompt)}자 ({step3_2_elapsed:.1f}ms)")
|
||||
|
||||
# Step 3-3: GPT API 호출
|
||||
step3_3_start = time.perf_counter()
|
||||
raw_response = await chatgpt_service.generate(prompt)
|
||||
step3_3_elapsed = (time.perf_counter() - step3_3_start) * 1000
|
||||
logger.info(f"[crawling] Step 3-3: GPT API 호출 완료 - 응답 {len(raw_response)}자 ({step3_3_elapsed:.1f}ms)")
|
||||
print(f"[crawling] Step 3-3: GPT API 호출 완료 - 응답 {len(raw_response)}자 ({step3_3_elapsed:.1f}ms)")
|
||||
|
||||
# Step 3-4: 응답 파싱
|
||||
step3_4_start = time.perf_counter()
|
||||
parsed = await chatgpt_service.parse_marketing_analysis(raw_response)
|
||||
marketing_analysis = MarketingAnalysis(**parsed)
|
||||
step3_4_elapsed = (time.perf_counter() - step3_4_start) * 1000
|
||||
print(f"[crawling] Step 3-4: 응답 파싱 완료 ({step3_4_elapsed:.1f}ms)")
|
||||
|
||||
step3_elapsed = (time.perf_counter() - step3_start) * 1000
|
||||
logger.info(f"[crawling] Step 3 완료 - 마케팅 분석 성공 ({step3_elapsed:.1f}ms)")
|
||||
print(f"[crawling] Step 3 완료 - 마케팅 분석 성공 ({step3_elapsed:.1f}ms)")
|
||||
|
||||
except Exception as e:
|
||||
step3_elapsed = (time.perf_counter() - step3_start) * 1000
|
||||
logger.error(f"[crawling] Step 3 FAILED - GPT 마케팅 분석 중 오류: {e} ({step3_elapsed:.1f}ms)")
|
||||
print(f"[crawling] Step 3 FAILED - {e} ({step3_elapsed:.1f}ms)")
|
||||
traceback.print_exc()
|
||||
# GPT 실패 시에도 크롤링 결과는 반환
|
||||
marketing_analysis = None
|
||||
else:
|
||||
step2_elapsed = (time.perf_counter() - step2_start) * 1000
|
||||
logger.warning(f"[crawling] Step 2 - base_info 없음 ({step2_elapsed:.1f}ms)")
|
||||
print(f"[crawling] Step 2 - base_info 없음, 마케팅 분석 스킵 ({step2_elapsed:.1f}ms)")
|
||||
|
||||
# ========== 완료 ==========
|
||||
total_elapsed = (time.perf_counter() - request_start) * 1000
|
||||
logger.info(f"[crawling] COMPLETE - 총 소요시간: {total_elapsed:.1f}ms")
|
||||
print(f"[crawling] ========== COMPLETE ==========")
|
||||
print(f"[crawling] 총 소요시간: {total_elapsed:.1f}ms")
|
||||
print(f"[crawling] - Step 1 (크롤링): {step1_elapsed:.1f}ms")
|
||||
if scraper.base_info:
|
||||
print(f"[crawling] - Step 2 (정보가공): {step2_elapsed:.1f}ms")
|
||||
if 'step3_elapsed' in locals():
|
||||
print(f"[crawling] - Step 3 (GPT 분석): {step3_elapsed:.1f}ms")
|
||||
if 'step3_3_elapsed' in locals():
|
||||
print(f"[crawling] - GPT API 호출: {step3_3_elapsed:.1f}ms")
|
||||
|
||||
return {
|
||||
"image_list": scraper.image_link_list,
|
||||
|
|
@ -274,7 +378,7 @@ async def upload_images(
|
|||
images_json: Optional[str] = Form(
|
||||
default=None,
|
||||
description="외부 이미지 URL 목록 (JSON 문자열)",
|
||||
example=IMAGES_JSON_EXAMPLE,
|
||||
examples=[IMAGES_JSON_EXAMPLE],
|
||||
),
|
||||
files: Optional[list[UploadFile]] = File(
|
||||
default=None, description="이미지 바이너리 파일 목록"
|
||||
|
|
@ -492,7 +596,7 @@ async def upload_images_blob(
|
|||
images_json: Optional[str] = Form(
|
||||
default=None,
|
||||
description="외부 이미지 URL 목록 (JSON 문자열)",
|
||||
example=IMAGES_JSON_EXAMPLE,
|
||||
examples=[IMAGES_JSON_EXAMPLE],
|
||||
),
|
||||
files: Optional[list[UploadFile]] = File(
|
||||
default=None, description="이미지 바이너리 파일 목록"
|
||||
|
|
@ -666,10 +770,21 @@ async def upload_images_blob(
|
|||
f"saved: {len(result_images)}, "
|
||||
f"elapsed: {(stage3_time - stage2_time)*1000:.1f}ms")
|
||||
|
||||
except SQLAlchemyError as e:
|
||||
logger.error(f"[upload_images_blob] DB Error - task_id: {task_id}, error: {e}")
|
||||
traceback.print_exc()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="이미지 저장 중 데이터베이스 오류가 발생했습니다.",
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"[upload_images_blob] Stage 3 EXCEPTION - "
|
||||
logger.error(f"[upload_images_blob] Stage 3 EXCEPTION - "
|
||||
f"task_id: {task_id}, error: {type(e).__name__}: {e}")
|
||||
raise
|
||||
traceback.print_exc()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="이미지 업로드 중 오류가 발생했습니다.",
|
||||
)
|
||||
|
||||
saved_count = len(result_images)
|
||||
image_urls = [img.img_url for img in result_images]
|
||||
|
|
|
|||
|
|
@ -220,15 +220,23 @@ async def generate_lyric(
|
|||
session: AsyncSession = Depends(get_session),
|
||||
) -> GenerateLyricResponse:
|
||||
"""고객 정보를 기반으로 가사를 생성합니다. (백그라운드 처리)"""
|
||||
import time
|
||||
|
||||
request_start = time.perf_counter()
|
||||
task_id = request_body.task_id
|
||||
|
||||
print(f"[generate_lyric] ========== START ==========")
|
||||
print(
|
||||
f"[generate_lyric] START - task_id: {task_id}, "
|
||||
f"[generate_lyric] task_id: {task_id}, "
|
||||
f"customer_name: {request_body.customer_name}, "
|
||||
f"region: {request_body.region}"
|
||||
)
|
||||
|
||||
try:
|
||||
# 1. ChatGPT 서비스 초기화 및 프롬프트 생성
|
||||
# ========== Step 1: ChatGPT 서비스 초기화 및 프롬프트 생성 ==========
|
||||
step1_start = time.perf_counter()
|
||||
print(f"[generate_lyric] Step 1: 서비스 초기화 및 프롬프트 생성...")
|
||||
|
||||
service = ChatgptService(
|
||||
customer_name=request_body.customer_name,
|
||||
region=request_body.region,
|
||||
|
|
@ -237,7 +245,13 @@ async def generate_lyric(
|
|||
)
|
||||
prompt = service.build_lyrics_prompt()
|
||||
|
||||
# 2. Project 테이블에 데이터 저장
|
||||
step1_elapsed = (time.perf_counter() - step1_start) * 1000
|
||||
print(f"[generate_lyric] Step 1 완료 - 프롬프트 {len(prompt)}자 ({step1_elapsed:.1f}ms)")
|
||||
|
||||
# ========== Step 2: Project 테이블에 데이터 저장 ==========
|
||||
step2_start = time.perf_counter()
|
||||
print(f"[generate_lyric] Step 2: Project 저장...")
|
||||
|
||||
project = Project(
|
||||
store_name=request_body.customer_name,
|
||||
region=request_body.region,
|
||||
|
|
@ -248,12 +262,14 @@ async def generate_lyric(
|
|||
session.add(project)
|
||||
await session.commit()
|
||||
await session.refresh(project)
|
||||
print(
|
||||
f"[generate_lyric] Project saved - "
|
||||
f"project_id: {project.id}, task_id: {task_id}"
|
||||
)
|
||||
|
||||
# 3. Lyric 테이블에 데이터 저장 (status: processing)
|
||||
step2_elapsed = (time.perf_counter() - step2_start) * 1000
|
||||
print(f"[generate_lyric] Step 2 완료 - project_id: {project.id} ({step2_elapsed:.1f}ms)")
|
||||
|
||||
# ========== Step 3: Lyric 테이블에 데이터 저장 ==========
|
||||
step3_start = time.perf_counter()
|
||||
print(f"[generate_lyric] Step 3: Lyric 저장 (processing)...")
|
||||
|
||||
lyric = Lyric(
|
||||
project_id=project.id,
|
||||
task_id=task_id,
|
||||
|
|
@ -265,19 +281,33 @@ async def generate_lyric(
|
|||
session.add(lyric)
|
||||
await session.commit()
|
||||
await session.refresh(lyric)
|
||||
print(
|
||||
f"[generate_lyric] Lyric saved (processing) - "
|
||||
f"lyric_id: {lyric.id}, task_id: {task_id}"
|
||||
)
|
||||
|
||||
# 4. 백그라운드 태스크로 ChatGPT 가사 생성 실행
|
||||
step3_elapsed = (time.perf_counter() - step3_start) * 1000
|
||||
print(f"[generate_lyric] Step 3 완료 - lyric_id: {lyric.id} ({step3_elapsed:.1f}ms)")
|
||||
|
||||
# ========== Step 4: 백그라운드 태스크 스케줄링 ==========
|
||||
step4_start = time.perf_counter()
|
||||
print(f"[generate_lyric] Step 4: 백그라운드 태스크 스케줄링...")
|
||||
|
||||
background_tasks.add_task(
|
||||
generate_lyric_background,
|
||||
task_id=task_id,
|
||||
prompt=prompt,
|
||||
language=request_body.language,
|
||||
)
|
||||
print(f"[generate_lyric] Background task scheduled - task_id: {task_id}")
|
||||
|
||||
step4_elapsed = (time.perf_counter() - step4_start) * 1000
|
||||
print(f"[generate_lyric] Step 4 완료 ({step4_elapsed:.1f}ms)")
|
||||
|
||||
# ========== 완료 ==========
|
||||
total_elapsed = (time.perf_counter() - request_start) * 1000
|
||||
print(f"[generate_lyric] ========== COMPLETE ==========")
|
||||
print(f"[generate_lyric] API 응답 소요시간: {total_elapsed:.1f}ms")
|
||||
print(f"[generate_lyric] - Step 1 (프롬프트 생성): {step1_elapsed:.1f}ms")
|
||||
print(f"[generate_lyric] - Step 2 (Project 저장): {step2_elapsed:.1f}ms")
|
||||
print(f"[generate_lyric] - Step 3 (Lyric 저장): {step3_elapsed:.1f}ms")
|
||||
print(f"[generate_lyric] - Step 4 (태스크 스케줄링): {step4_elapsed:.1f}ms")
|
||||
print(f"[generate_lyric] (GPT API 호출은 백그라운드에서 별도 진행)")
|
||||
|
||||
# 5. 즉시 응답 반환
|
||||
return GenerateLyricResponse(
|
||||
|
|
@ -289,7 +319,8 @@ async def generate_lyric(
|
|||
)
|
||||
|
||||
except Exception as e:
|
||||
print(f"[generate_lyric] EXCEPTION - task_id: {task_id}, error: {e}")
|
||||
elapsed = (time.perf_counter() - request_start) * 1000
|
||||
print(f"[generate_lyric] EXCEPTION - task_id: {task_id}, error: {e} ({elapsed:.1f}ms)")
|
||||
await session.rollback()
|
||||
return GenerateLyricResponse(
|
||||
success=False,
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ class SongFormData:
|
|||
attributes: Dict[str, str] = field(default_factory=dict)
|
||||
attributes_str: str = ""
|
||||
lyrics_ids: List[int] = field(default_factory=list)
|
||||
llm_model: str = "gpt-4o"
|
||||
llm_model: str = "gpt-5-mini"
|
||||
|
||||
@classmethod
|
||||
async def from_form(cls, request: Request):
|
||||
|
|
@ -86,6 +86,6 @@ class SongFormData:
|
|||
attributes=attributes,
|
||||
attributes_str=attributes_str,
|
||||
lyrics_ids=lyrics_ids,
|
||||
llm_model=form_data.get("llm_model", "gpt-4o"),
|
||||
llm_model=form_data.get("llm_model", "gpt-5-mini"),
|
||||
prompts=form_data.get("prompts", ""),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -783,7 +783,7 @@ async def make_automation(request: Request, conn: Connection):
|
|||
else "",
|
||||
"attr_value": ", ".join(selected_values) if selected_values else "",
|
||||
"ai": "ChatGPT",
|
||||
"ai_model": "gpt-4o",
|
||||
"ai_model": "gpt-5-mini",
|
||||
"genre": "후크송",
|
||||
"sample_song": combined_sample_song or "없음",
|
||||
"result_song": final_lyrics,
|
||||
|
|
|
|||
|
|
@ -4,12 +4,67 @@ Lyric Background Tasks
|
|||
가사 생성 관련 백그라운드 태스크를 정의합니다.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import traceback
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
|
||||
from app.database.session import BackgroundSessionLocal
|
||||
from app.lyric.models import Lyric
|
||||
from app.utils.chatgpt_prompt import ChatgptService
|
||||
|
||||
# 로거 설정
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def _update_lyric_status(
|
||||
task_id: str,
|
||||
status: str,
|
||||
result: str | None = None,
|
||||
) -> bool:
|
||||
"""Lyric 테이블의 상태를 업데이트합니다.
|
||||
|
||||
Args:
|
||||
task_id: 프로젝트 task_id
|
||||
status: 변경할 상태 ("processing", "completed", "failed")
|
||||
result: 가사 결과 또는 에러 메시지
|
||||
|
||||
Returns:
|
||||
bool: 업데이트 성공 여부
|
||||
"""
|
||||
try:
|
||||
async with BackgroundSessionLocal() as session:
|
||||
query_result = await session.execute(
|
||||
select(Lyric)
|
||||
.where(Lyric.task_id == task_id)
|
||||
.order_by(Lyric.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
lyric = query_result.scalar_one_or_none()
|
||||
|
||||
if lyric:
|
||||
lyric.status = status
|
||||
if result is not None:
|
||||
lyric.lyric_result = result
|
||||
await session.commit()
|
||||
logger.info(f"[Lyric] Status updated - task_id: {task_id}, status: {status}")
|
||||
print(f"[Lyric] Status updated - task_id: {task_id}, status: {status}")
|
||||
return True
|
||||
else:
|
||||
logger.warning(f"[Lyric] NOT FOUND in DB - task_id: {task_id}")
|
||||
print(f"[Lyric] NOT FOUND in DB - task_id: {task_id}")
|
||||
return False
|
||||
|
||||
except SQLAlchemyError as e:
|
||||
logger.error(f"[Lyric] DB Error while updating status - task_id: {task_id}, error: {e}")
|
||||
print(f"[Lyric] DB Error while updating status - task_id: {task_id}, error: {e}")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"[Lyric] Unexpected error while updating status - task_id: {task_id}, error: {e}")
|
||||
print(f"[Lyric] Unexpected error while updating status - task_id: {task_id}, error: {e}")
|
||||
return False
|
||||
|
||||
|
||||
async def generate_lyric_background(
|
||||
task_id: str,
|
||||
|
|
@ -23,10 +78,20 @@ async def generate_lyric_background(
|
|||
prompt: ChatGPT에 전달할 프롬프트
|
||||
language: 가사 언어
|
||||
"""
|
||||
print(f"[generate_lyric_background] START - task_id: {task_id}")
|
||||
import time
|
||||
|
||||
task_start = time.perf_counter()
|
||||
logger.info(f"[generate_lyric_background] START - task_id: {task_id}")
|
||||
print(f"[generate_lyric_background] ========== START ==========")
|
||||
print(f"[generate_lyric_background] task_id: {task_id}")
|
||||
print(f"[generate_lyric_background] language: {language}")
|
||||
print(f"[generate_lyric_background] prompt length: {len(prompt)}자")
|
||||
|
||||
try:
|
||||
# ChatGPT 서비스 초기화 (프롬프트는 이미 생성되어 있음)
|
||||
# ========== Step 1: ChatGPT 서비스 초기화 ==========
|
||||
step1_start = time.perf_counter()
|
||||
print(f"[generate_lyric_background] Step 1: ChatGPT 서비스 초기화...")
|
||||
|
||||
service = ChatgptService(
|
||||
customer_name="", # 프롬프트가 이미 생성되었으므로 빈 값
|
||||
region="",
|
||||
|
|
@ -34,65 +99,48 @@ async def generate_lyric_background(
|
|||
language=language,
|
||||
)
|
||||
|
||||
# ChatGPT를 통해 가사 생성
|
||||
print(f"[generate_lyric_background] ChatGPT generation started - task_id: {task_id}")
|
||||
step1_elapsed = (time.perf_counter() - step1_start) * 1000
|
||||
print(f"[generate_lyric_background] Step 1 완료 ({step1_elapsed:.1f}ms)")
|
||||
|
||||
# ========== Step 2: ChatGPT API 호출 (가사 생성) ==========
|
||||
step2_start = time.perf_counter()
|
||||
logger.info(f"[generate_lyric_background] Step 2: ChatGPT API 호출 시작 - task_id: {task_id}")
|
||||
print(f"[generate_lyric_background] Step 2: ChatGPT API 호출 시작...")
|
||||
|
||||
result = await service.generate(prompt=prompt)
|
||||
print(f"[generate_lyric_background] ChatGPT generation completed - task_id: {task_id}")
|
||||
|
||||
# 실패 응답 검사 (ERROR 또는 ChatGPT 거부 응답)
|
||||
failure_patterns = [
|
||||
"ERROR:",
|
||||
"I'm sorry",
|
||||
"I cannot",
|
||||
"I can't",
|
||||
"I apologize",
|
||||
"I'm unable",
|
||||
"I am unable",
|
||||
"I'm not able",
|
||||
"I am not able",
|
||||
]
|
||||
is_failure = any(
|
||||
pattern.lower() in result.lower() for pattern in failure_patterns
|
||||
)
|
||||
step2_elapsed = (time.perf_counter() - step2_start) * 1000
|
||||
logger.info(f"[generate_lyric_background] Step 2 완료 - 응답 {len(result)}자 ({step2_elapsed:.1f}ms)")
|
||||
print(f"[generate_lyric_background] Step 2 완료 - 응답 {len(result)}자 ({step2_elapsed:.1f}ms)")
|
||||
|
||||
# Lyric 테이블 업데이트 (백그라운드 전용 세션 사용)
|
||||
async with BackgroundSessionLocal() as session:
|
||||
query_result = await session.execute(
|
||||
select(Lyric)
|
||||
.where(Lyric.task_id == task_id)
|
||||
.order_by(Lyric.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
lyric = query_result.scalar_one_or_none()
|
||||
# ========== Step 3: DB 상태 업데이트 ==========
|
||||
step3_start = time.perf_counter()
|
||||
print(f"[generate_lyric_background] Step 3: DB 상태 업데이트...")
|
||||
|
||||
if lyric:
|
||||
if is_failure:
|
||||
print(f"[generate_lyric_background] FAILED - task_id: {task_id}, error: {result}")
|
||||
lyric.status = "failed"
|
||||
lyric.lyric_result = result
|
||||
else:
|
||||
print(f"[generate_lyric_background] SUCCESS - task_id: {task_id}")
|
||||
lyric.status = "completed"
|
||||
lyric.lyric_result = result
|
||||
await _update_lyric_status(task_id, "completed", result)
|
||||
|
||||
await session.commit()
|
||||
else:
|
||||
print(f"[generate_lyric_background] Lyric NOT FOUND in DB - task_id: {task_id}")
|
||||
step3_elapsed = (time.perf_counter() - step3_start) * 1000
|
||||
print(f"[generate_lyric_background] Step 3 완료 ({step3_elapsed:.1f}ms)")
|
||||
|
||||
# ========== 완료 ==========
|
||||
total_elapsed = (time.perf_counter() - task_start) * 1000
|
||||
logger.info(f"[generate_lyric_background] SUCCESS - task_id: {task_id}, 총 소요시간: {total_elapsed:.1f}ms")
|
||||
print(f"[generate_lyric_background] ========== SUCCESS ==========")
|
||||
print(f"[generate_lyric_background] 총 소요시간: {total_elapsed:.1f}ms")
|
||||
print(f"[generate_lyric_background] - Step 1 (서비스 초기화): {step1_elapsed:.1f}ms")
|
||||
print(f"[generate_lyric_background] - Step 2 (GPT API 호출): {step2_elapsed:.1f}ms")
|
||||
print(f"[generate_lyric_background] - Step 3 (DB 업데이트): {step3_elapsed:.1f}ms")
|
||||
|
||||
except SQLAlchemyError as e:
|
||||
elapsed = (time.perf_counter() - task_start) * 1000
|
||||
logger.error(f"[generate_lyric_background] DB ERROR - task_id: {task_id}, error: {e} ({elapsed:.1f}ms)")
|
||||
print(f"[generate_lyric_background] DB ERROR - {e} ({elapsed:.1f}ms)")
|
||||
traceback.print_exc()
|
||||
await _update_lyric_status(task_id, "failed", f"Database Error: {str(e)}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"[generate_lyric_background] EXCEPTION - task_id: {task_id}, error: {e}")
|
||||
# 실패 시 Lyric 테이블 업데이트
|
||||
async with BackgroundSessionLocal() as session:
|
||||
query_result = await session.execute(
|
||||
select(Lyric)
|
||||
.where(Lyric.task_id == task_id)
|
||||
.order_by(Lyric.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
lyric = query_result.scalar_one_or_none()
|
||||
|
||||
if lyric:
|
||||
lyric.status = "failed"
|
||||
lyric.lyric_result = f"Error: {str(e)}"
|
||||
await session.commit()
|
||||
print(f"[generate_lyric_background] FAILED - task_id: {task_id}, status updated to failed")
|
||||
elapsed = (time.perf_counter() - task_start) * 1000
|
||||
logger.error(f"[generate_lyric_background] EXCEPTION - task_id: {task_id}, error: {e} ({elapsed:.1f}ms)")
|
||||
print(f"[generate_lyric_background] EXCEPTION - {e} ({elapsed:.1f}ms)")
|
||||
traceback.print_exc()
|
||||
await _update_lyric_status(task_id, "failed", f"Error: {str(e)}")
|
||||
|
|
|
|||
|
|
@ -335,7 +335,7 @@ class SongFormData:
|
|||
attributes: Dict[str, str] = field(default_factory=dict)
|
||||
attributes_str: str = ""
|
||||
lyrics_ids: List[int] = field(default_factory=list)
|
||||
llm_model: str = "gpt-4o"
|
||||
llm_model: str = "gpt-5-mini"
|
||||
|
||||
@classmethod
|
||||
async def from_form(cls, request: Request):
|
||||
|
|
@ -369,6 +369,6 @@ class SongFormData:
|
|||
attributes=attributes,
|
||||
attributes_str=attributes_str,
|
||||
lyrics_ids=lyrics_ids,
|
||||
llm_model=form_data.get("llm_model", "gpt-4o"),
|
||||
llm_model=form_data.get("llm_model", "gpt-5-mini"),
|
||||
prompts=form_data.get("prompts", ""),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -783,7 +783,7 @@ async def make_automation(request: Request, conn: Connection):
|
|||
else "",
|
||||
"attr_value": ", ".join(selected_values) if selected_values else "",
|
||||
"ai": "ChatGPT",
|
||||
"ai_model": "gpt-4o",
|
||||
"ai_model": "gpt-5-mini",
|
||||
"genre": "후크송",
|
||||
"sample_song": combined_sample_song or "없음",
|
||||
"result_song": final_lyrics,
|
||||
|
|
|
|||
|
|
@ -4,12 +4,15 @@ Song Background Tasks
|
|||
노래 생성 관련 백그라운드 태스크를 정의합니다.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import traceback
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
|
||||
import aiofiles
|
||||
import httpx
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
|
||||
from app.database.session import BackgroundSessionLocal
|
||||
from app.song.models import Song
|
||||
|
|
@ -17,6 +20,100 @@ from app.utils.common import generate_task_id
|
|||
from app.utils.upload_blob_as_request import AzureBlobUploader
|
||||
from config import prj_settings
|
||||
|
||||
# 로거 설정
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# HTTP 요청 설정
|
||||
REQUEST_TIMEOUT = 120.0 # 초
|
||||
|
||||
|
||||
async def _update_song_status(
|
||||
task_id: str,
|
||||
status: str,
|
||||
song_url: str | None = None,
|
||||
suno_task_id: str | None = None,
|
||||
duration: float | None = None,
|
||||
) -> bool:
|
||||
"""Song 테이블의 상태를 업데이트합니다.
|
||||
|
||||
Args:
|
||||
task_id: 프로젝트 task_id
|
||||
status: 변경할 상태 ("processing", "completed", "failed")
|
||||
song_url: 노래 URL
|
||||
suno_task_id: Suno task ID (선택)
|
||||
duration: 노래 길이 (선택)
|
||||
|
||||
Returns:
|
||||
bool: 업데이트 성공 여부
|
||||
"""
|
||||
try:
|
||||
async with BackgroundSessionLocal() as session:
|
||||
if suno_task_id:
|
||||
query_result = await session.execute(
|
||||
select(Song)
|
||||
.where(Song.suno_task_id == suno_task_id)
|
||||
.order_by(Song.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
else:
|
||||
query_result = await session.execute(
|
||||
select(Song)
|
||||
.where(Song.task_id == task_id)
|
||||
.order_by(Song.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
|
||||
song = query_result.scalar_one_or_none()
|
||||
|
||||
if song:
|
||||
song.status = status
|
||||
if song_url is not None:
|
||||
song.song_result_url = song_url
|
||||
if duration is not None:
|
||||
song.duration = duration
|
||||
await session.commit()
|
||||
logger.info(f"[Song] Status updated - task_id: {task_id}, status: {status}")
|
||||
print(f"[Song] Status updated - task_id: {task_id}, status: {status}")
|
||||
return True
|
||||
else:
|
||||
logger.warning(f"[Song] NOT FOUND in DB - task_id: {task_id}")
|
||||
print(f"[Song] NOT FOUND in DB - task_id: {task_id}")
|
||||
return False
|
||||
|
||||
except SQLAlchemyError as e:
|
||||
logger.error(f"[Song] DB Error while updating status - task_id: {task_id}, error: {e}")
|
||||
print(f"[Song] DB Error while updating status - task_id: {task_id}, error: {e}")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"[Song] Unexpected error while updating status - task_id: {task_id}, error: {e}")
|
||||
print(f"[Song] Unexpected error while updating status - task_id: {task_id}, error: {e}")
|
||||
return False
|
||||
|
||||
|
||||
async def _download_audio(url: str, task_id: str) -> bytes:
|
||||
"""URL에서 오디오 파일을 다운로드합니다.
|
||||
|
||||
Args:
|
||||
url: 다운로드할 URL
|
||||
task_id: 로그용 task_id
|
||||
|
||||
Returns:
|
||||
bytes: 다운로드한 파일 내용
|
||||
|
||||
Raises:
|
||||
httpx.HTTPError: 다운로드 실패 시
|
||||
"""
|
||||
logger.info(f"[Download] Downloading - task_id: {task_id}")
|
||||
print(f"[Download] Downloading - task_id: {task_id}")
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(url, timeout=REQUEST_TIMEOUT)
|
||||
response.raise_for_status()
|
||||
|
||||
logger.info(f"[Download] SUCCESS - task_id: {task_id}, size: {len(response.content)} bytes")
|
||||
print(f"[Download] SUCCESS - task_id: {task_id}, size: {len(response.content)} bytes")
|
||||
return response.content
|
||||
|
||||
|
||||
async def download_and_save_song(
|
||||
task_id: str,
|
||||
|
|
@ -30,7 +127,9 @@ async def download_and_save_song(
|
|||
audio_url: 다운로드할 오디오 URL
|
||||
store_name: 저장할 파일명에 사용할 업체명
|
||||
"""
|
||||
logger.info(f"[download_and_save_song] START - task_id: {task_id}, store_name: {store_name}")
|
||||
print(f"[download_and_save_song] START - task_id: {task_id}, store_name: {store_name}")
|
||||
|
||||
try:
|
||||
# 저장 경로 생성: media/song/{날짜}/{uuid7}/{store_name}.mp3
|
||||
today = date.today().strftime("%Y-%m-%d")
|
||||
|
|
@ -46,60 +145,50 @@ async def download_and_save_song(
|
|||
media_dir = Path("media") / "song" / today / unique_id
|
||||
media_dir.mkdir(parents=True, exist_ok=True)
|
||||
file_path = media_dir / file_name
|
||||
logger.info(f"[download_and_save_song] Directory created - path: {file_path}")
|
||||
print(f"[download_and_save_song] Directory created - path: {file_path}")
|
||||
|
||||
# 오디오 파일 다운로드
|
||||
logger.info(f"[download_and_save_song] Downloading audio - task_id: {task_id}, url: {audio_url}")
|
||||
print(f"[download_and_save_song] Downloading audio - task_id: {task_id}, url: {audio_url}")
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(audio_url, timeout=60.0)
|
||||
response.raise_for_status()
|
||||
|
||||
content = await _download_audio(audio_url, task_id)
|
||||
|
||||
async with aiofiles.open(str(file_path), "wb") as f:
|
||||
await f.write(response.content)
|
||||
await f.write(content)
|
||||
|
||||
logger.info(f"[download_and_save_song] File saved - task_id: {task_id}, path: {file_path}")
|
||||
print(f"[download_and_save_song] File saved - task_id: {task_id}, path: {file_path}")
|
||||
|
||||
# 프론트엔드에서 접근 가능한 URL 생성
|
||||
relative_path = f"/media/song/{today}/{unique_id}/{file_name}"
|
||||
base_url = f"http://{prj_settings.PROJECT_DOMAIN}"
|
||||
file_url = f"{base_url}{relative_path}"
|
||||
logger.info(f"[download_and_save_song] URL generated - task_id: {task_id}, url: {file_url}")
|
||||
print(f"[download_and_save_song] URL generated - task_id: {task_id}, url: {file_url}")
|
||||
|
||||
# Song 테이블 업데이트 (새 세션 사용)
|
||||
async with BackgroundSessionLocal() as session:
|
||||
# 여러 개 있을 경우 가장 최근 것 선택
|
||||
result = await session.execute(
|
||||
select(Song)
|
||||
.where(Song.task_id == task_id)
|
||||
.order_by(Song.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
song = result.scalar_one_or_none()
|
||||
# Song 테이블 업데이트
|
||||
await _update_song_status(task_id, "completed", file_url)
|
||||
logger.info(f"[download_and_save_song] SUCCESS - task_id: {task_id}")
|
||||
print(f"[download_and_save_song] SUCCESS - task_id: {task_id}")
|
||||
|
||||
if song:
|
||||
song.status = "completed"
|
||||
song.song_result_url = file_url
|
||||
await session.commit()
|
||||
print(f"[download_and_save_song] SUCCESS - task_id: {task_id}, status: completed")
|
||||
else:
|
||||
print(f"[download_and_save_song] Song NOT FOUND in DB - task_id: {task_id}")
|
||||
except httpx.HTTPError as e:
|
||||
logger.error(f"[download_and_save_song] DOWNLOAD ERROR - task_id: {task_id}, error: {e}")
|
||||
print(f"[download_and_save_song] DOWNLOAD ERROR - task_id: {task_id}, error: {e}")
|
||||
traceback.print_exc()
|
||||
await _update_song_status(task_id, "failed")
|
||||
|
||||
except SQLAlchemyError as e:
|
||||
logger.error(f"[download_and_save_song] DB ERROR - task_id: {task_id}, error: {e}")
|
||||
print(f"[download_and_save_song] DB ERROR - task_id: {task_id}, error: {e}")
|
||||
traceback.print_exc()
|
||||
await _update_song_status(task_id, "failed")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[download_and_save_song] EXCEPTION - task_id: {task_id}, error: {e}")
|
||||
print(f"[download_and_save_song] EXCEPTION - task_id: {task_id}, error: {e}")
|
||||
# 실패 시 Song 테이블 업데이트
|
||||
async with BackgroundSessionLocal() as session:
|
||||
# 여러 개 있을 경우 가장 최근 것 선택
|
||||
result = await session.execute(
|
||||
select(Song)
|
||||
.where(Song.task_id == task_id)
|
||||
.order_by(Song.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
song = result.scalar_one_or_none()
|
||||
|
||||
if song:
|
||||
song.status = "failed"
|
||||
await session.commit()
|
||||
print(f"[download_and_save_song] FAILED - task_id: {task_id}, status updated to failed")
|
||||
traceback.print_exc()
|
||||
await _update_song_status(task_id, "failed")
|
||||
|
||||
|
||||
async def download_and_upload_song_to_blob(
|
||||
|
|
@ -114,6 +203,7 @@ async def download_and_upload_song_to_blob(
|
|||
audio_url: 다운로드할 오디오 URL
|
||||
store_name: 저장할 파일명에 사용할 업체명
|
||||
"""
|
||||
logger.info(f"[download_and_upload_song_to_blob] START - task_id: {task_id}, store_name: {store_name}")
|
||||
print(f"[download_and_upload_song_to_blob] START - task_id: {task_id}, store_name: {store_name}")
|
||||
temp_file_path: Path | None = None
|
||||
|
||||
|
|
@ -129,16 +219,19 @@ async def download_and_upload_song_to_blob(
|
|||
temp_dir = Path("media") / "temp" / task_id
|
||||
temp_dir.mkdir(parents=True, exist_ok=True)
|
||||
temp_file_path = temp_dir / file_name
|
||||
logger.info(f"[download_and_upload_song_to_blob] Temp directory created - path: {temp_file_path}")
|
||||
print(f"[download_and_upload_song_to_blob] Temp directory created - path: {temp_file_path}")
|
||||
|
||||
# 오디오 파일 다운로드
|
||||
logger.info(f"[download_and_upload_song_to_blob] Downloading audio - task_id: {task_id}, url: {audio_url}")
|
||||
print(f"[download_and_upload_song_to_blob] Downloading audio - task_id: {task_id}, url: {audio_url}")
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(audio_url, timeout=60.0)
|
||||
response.raise_for_status()
|
||||
|
||||
content = await _download_audio(audio_url, task_id)
|
||||
|
||||
async with aiofiles.open(str(temp_file_path), "wb") as f:
|
||||
await f.write(response.content)
|
||||
await f.write(content)
|
||||
|
||||
logger.info(f"[download_and_upload_song_to_blob] File downloaded - task_id: {task_id}, path: {temp_file_path}")
|
||||
print(f"[download_and_upload_song_to_blob] File downloaded - task_id: {task_id}, path: {temp_file_path}")
|
||||
|
||||
# Azure Blob Storage에 업로드
|
||||
|
|
@ -150,51 +243,41 @@ async def download_and_upload_song_to_blob(
|
|||
|
||||
# SAS 토큰이 제외된 public_url 사용
|
||||
blob_url = uploader.public_url
|
||||
logger.info(f"[download_and_upload_song_to_blob] Uploaded to Blob - task_id: {task_id}, url: {blob_url}")
|
||||
print(f"[download_and_upload_song_to_blob] Uploaded to Blob - task_id: {task_id}, url: {blob_url}")
|
||||
|
||||
# Song 테이블 업데이트 (새 세션 사용)
|
||||
async with BackgroundSessionLocal() as session:
|
||||
# 여러 개 있을 경우 가장 최근 것 선택
|
||||
result = await session.execute(
|
||||
select(Song)
|
||||
.where(Song.task_id == task_id)
|
||||
.order_by(Song.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
song = result.scalar_one_or_none()
|
||||
# Song 테이블 업데이트
|
||||
await _update_song_status(task_id, "completed", blob_url)
|
||||
logger.info(f"[download_and_upload_song_to_blob] SUCCESS - task_id: {task_id}")
|
||||
print(f"[download_and_upload_song_to_blob] SUCCESS - task_id: {task_id}")
|
||||
|
||||
if song:
|
||||
song.status = "completed"
|
||||
song.song_result_url = blob_url
|
||||
await session.commit()
|
||||
print(f"[download_and_upload_song_to_blob] SUCCESS - task_id: {task_id}, status: completed")
|
||||
else:
|
||||
print(f"[download_and_upload_song_to_blob] Song NOT FOUND in DB - task_id: {task_id}")
|
||||
except httpx.HTTPError as e:
|
||||
logger.error(f"[download_and_upload_song_to_blob] DOWNLOAD ERROR - task_id: {task_id}, error: {e}")
|
||||
print(f"[download_and_upload_song_to_blob] DOWNLOAD ERROR - task_id: {task_id}, error: {e}")
|
||||
traceback.print_exc()
|
||||
await _update_song_status(task_id, "failed")
|
||||
|
||||
except SQLAlchemyError as e:
|
||||
logger.error(f"[download_and_upload_song_to_blob] DB ERROR - task_id: {task_id}, error: {e}")
|
||||
print(f"[download_and_upload_song_to_blob] DB ERROR - task_id: {task_id}, error: {e}")
|
||||
traceback.print_exc()
|
||||
await _update_song_status(task_id, "failed")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[download_and_upload_song_to_blob] EXCEPTION - task_id: {task_id}, error: {e}")
|
||||
print(f"[download_and_upload_song_to_blob] EXCEPTION - task_id: {task_id}, error: {e}")
|
||||
# 실패 시 Song 테이블 업데이트
|
||||
async with BackgroundSessionLocal() as session:
|
||||
result = await session.execute(
|
||||
select(Song)
|
||||
.where(Song.task_id == task_id)
|
||||
.order_by(Song.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
song = result.scalar_one_or_none()
|
||||
|
||||
if song:
|
||||
song.status = "failed"
|
||||
await session.commit()
|
||||
print(f"[download_and_upload_song_to_blob] FAILED - task_id: {task_id}, status updated to failed")
|
||||
traceback.print_exc()
|
||||
await _update_song_status(task_id, "failed")
|
||||
|
||||
finally:
|
||||
# 임시 파일 삭제
|
||||
if temp_file_path and temp_file_path.exists():
|
||||
try:
|
||||
temp_file_path.unlink()
|
||||
logger.info(f"[download_and_upload_song_to_blob] Temp file deleted - path: {temp_file_path}")
|
||||
print(f"[download_and_upload_song_to_blob] Temp file deleted - path: {temp_file_path}")
|
||||
except Exception as e:
|
||||
logger.warning(f"[download_and_upload_song_to_blob] Failed to delete temp file: {e}")
|
||||
print(f"[download_and_upload_song_to_blob] Failed to delete temp file: {e}")
|
||||
|
||||
# 임시 디렉토리 삭제 시도
|
||||
|
|
@ -220,6 +303,7 @@ async def download_and_upload_song_by_suno_task_id(
|
|||
store_name: 저장할 파일명에 사용할 업체명
|
||||
duration: 노래 재생 시간 (초)
|
||||
"""
|
||||
logger.info(f"[download_and_upload_song_by_suno_task_id] START - suno_task_id: {suno_task_id}, store_name: {store_name}, duration: {duration}")
|
||||
print(f"[download_and_upload_song_by_suno_task_id] START - suno_task_id: {suno_task_id}, store_name: {store_name}, duration: {duration}")
|
||||
temp_file_path: Path | None = None
|
||||
task_id: str | None = None
|
||||
|
|
@ -236,10 +320,12 @@ async def download_and_upload_song_by_suno_task_id(
|
|||
song = result.scalar_one_or_none()
|
||||
|
||||
if not song:
|
||||
logger.warning(f"[download_and_upload_song_by_suno_task_id] Song NOT FOUND - suno_task_id: {suno_task_id}")
|
||||
print(f"[download_and_upload_song_by_suno_task_id] Song NOT FOUND - suno_task_id: {suno_task_id}")
|
||||
return
|
||||
|
||||
task_id = song.task_id
|
||||
logger.info(f"[download_and_upload_song_by_suno_task_id] Song found - suno_task_id: {suno_task_id}, task_id: {task_id}")
|
||||
print(f"[download_and_upload_song_by_suno_task_id] Song found - suno_task_id: {suno_task_id}, task_id: {task_id}")
|
||||
|
||||
# 파일명에 사용할 수 없는 문자 제거
|
||||
|
|
@ -253,16 +339,19 @@ async def download_and_upload_song_by_suno_task_id(
|
|||
temp_dir = Path("media") / "temp" / task_id
|
||||
temp_dir.mkdir(parents=True, exist_ok=True)
|
||||
temp_file_path = temp_dir / file_name
|
||||
logger.info(f"[download_and_upload_song_by_suno_task_id] Temp directory created - path: {temp_file_path}")
|
||||
print(f"[download_and_upload_song_by_suno_task_id] Temp directory created - path: {temp_file_path}")
|
||||
|
||||
# 오디오 파일 다운로드
|
||||
logger.info(f"[download_and_upload_song_by_suno_task_id] Downloading audio - suno_task_id: {suno_task_id}, url: {audio_url}")
|
||||
print(f"[download_and_upload_song_by_suno_task_id] Downloading audio - suno_task_id: {suno_task_id}, url: {audio_url}")
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(audio_url, timeout=60.0)
|
||||
response.raise_for_status()
|
||||
|
||||
content = await _download_audio(audio_url, task_id)
|
||||
|
||||
async with aiofiles.open(str(temp_file_path), "wb") as f:
|
||||
await f.write(response.content)
|
||||
await f.write(content)
|
||||
|
||||
logger.info(f"[download_and_upload_song_by_suno_task_id] File downloaded - suno_task_id: {suno_task_id}, path: {temp_file_path}")
|
||||
print(f"[download_and_upload_song_by_suno_task_id] File downloaded - suno_task_id: {suno_task_id}, path: {temp_file_path}")
|
||||
|
||||
# Azure Blob Storage에 업로드
|
||||
|
|
@ -274,53 +363,50 @@ async def download_and_upload_song_by_suno_task_id(
|
|||
|
||||
# SAS 토큰이 제외된 public_url 사용
|
||||
blob_url = uploader.public_url
|
||||
logger.info(f"[download_and_upload_song_by_suno_task_id] Uploaded to Blob - suno_task_id: {suno_task_id}, url: {blob_url}")
|
||||
print(f"[download_and_upload_song_by_suno_task_id] Uploaded to Blob - suno_task_id: {suno_task_id}, url: {blob_url}")
|
||||
|
||||
# Song 테이블 업데이트 (새 세션 사용)
|
||||
async with BackgroundSessionLocal() as session:
|
||||
result = await session.execute(
|
||||
select(Song)
|
||||
.where(Song.suno_task_id == suno_task_id)
|
||||
.order_by(Song.created_at.desc())
|
||||
.limit(1)
|
||||
# Song 테이블 업데이트
|
||||
await _update_song_status(
|
||||
task_id=task_id,
|
||||
status="completed",
|
||||
song_url=blob_url,
|
||||
suno_task_id=suno_task_id,
|
||||
duration=duration,
|
||||
)
|
||||
song = result.scalar_one_or_none()
|
||||
logger.info(f"[download_and_upload_song_by_suno_task_id] SUCCESS - suno_task_id: {suno_task_id}, duration: {duration}")
|
||||
print(f"[download_and_upload_song_by_suno_task_id] SUCCESS - suno_task_id: {suno_task_id}, duration: {duration}")
|
||||
|
||||
if song:
|
||||
song.status = "completed"
|
||||
song.song_result_url = blob_url
|
||||
if duration is not None:
|
||||
song.duration = duration
|
||||
await session.commit()
|
||||
print(f"[download_and_upload_song_by_suno_task_id] SUCCESS - suno_task_id: {suno_task_id}, status: completed, duration: {duration}")
|
||||
else:
|
||||
print(f"[download_and_upload_song_by_suno_task_id] Song NOT FOUND in DB - suno_task_id: {suno_task_id}")
|
||||
except httpx.HTTPError as e:
|
||||
logger.error(f"[download_and_upload_song_by_suno_task_id] DOWNLOAD ERROR - suno_task_id: {suno_task_id}, error: {e}")
|
||||
print(f"[download_and_upload_song_by_suno_task_id] DOWNLOAD ERROR - suno_task_id: {suno_task_id}, error: {e}")
|
||||
traceback.print_exc()
|
||||
if task_id:
|
||||
await _update_song_status(task_id, "failed", suno_task_id=suno_task_id)
|
||||
|
||||
except SQLAlchemyError as e:
|
||||
logger.error(f"[download_and_upload_song_by_suno_task_id] DB ERROR - suno_task_id: {suno_task_id}, error: {e}")
|
||||
print(f"[download_and_upload_song_by_suno_task_id] DB ERROR - suno_task_id: {suno_task_id}, error: {e}")
|
||||
traceback.print_exc()
|
||||
if task_id:
|
||||
await _update_song_status(task_id, "failed", suno_task_id=suno_task_id)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[download_and_upload_song_by_suno_task_id] EXCEPTION - suno_task_id: {suno_task_id}, error: {e}")
|
||||
print(f"[download_and_upload_song_by_suno_task_id] EXCEPTION - suno_task_id: {suno_task_id}, error: {e}")
|
||||
# 실패 시 Song 테이블 업데이트
|
||||
traceback.print_exc()
|
||||
if task_id:
|
||||
async with BackgroundSessionLocal() as session:
|
||||
result = await session.execute(
|
||||
select(Song)
|
||||
.where(Song.suno_task_id == suno_task_id)
|
||||
.order_by(Song.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
song = result.scalar_one_or_none()
|
||||
|
||||
if song:
|
||||
song.status = "failed"
|
||||
await session.commit()
|
||||
print(f"[download_and_upload_song_by_suno_task_id] FAILED - suno_task_id: {suno_task_id}, status updated to failed")
|
||||
await _update_song_status(task_id, "failed", suno_task_id=suno_task_id)
|
||||
|
||||
finally:
|
||||
# 임시 파일 삭제
|
||||
if temp_file_path and temp_file_path.exists():
|
||||
try:
|
||||
temp_file_path.unlink()
|
||||
logger.info(f"[download_and_upload_song_by_suno_task_id] Temp file deleted - path: {temp_file_path}")
|
||||
print(f"[download_and_upload_song_by_suno_task_id] Temp file deleted - path: {temp_file_path}")
|
||||
except Exception as e:
|
||||
logger.warning(f"[download_and_upload_song_by_suno_task_id] Failed to delete temp file: {e}")
|
||||
print(f"[download_and_upload_song_by_suno_task_id] Failed to delete temp file: {e}")
|
||||
|
||||
# 임시 디렉토리 삭제 시도
|
||||
|
|
|
|||
|
|
@ -1,10 +1,14 @@
|
|||
import json
|
||||
import logging
|
||||
import re
|
||||
|
||||
from openai import AsyncOpenAI
|
||||
|
||||
from config import apikey_settings
|
||||
|
||||
# 로거 설정
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# fmt: off
|
||||
LYRICS_PROMPT_TEMPLATE_ORI = """
|
||||
1.Act as a content marketing expert with domain knowledges in [pension/staying services] in Korea, Goal: plan viral content creation that lead online reservations and promotion
|
||||
|
|
@ -215,6 +219,11 @@ ERROR: [Brief reason for failure in English]
|
|||
|
||||
|
||||
class ChatgptService:
|
||||
"""ChatGPT API 서비스 클래스
|
||||
|
||||
GPT 5.0 모델을 사용하여 마케팅 가사 및 분석을 생성합니다.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
customer_name: str,
|
||||
|
|
@ -222,9 +231,8 @@ class ChatgptService:
|
|||
detail_region_info: str = "",
|
||||
language: str = "Korean",
|
||||
):
|
||||
# 최신 모델: GPT-5, GPT-5 mini, GPT-5 nano, GPT-4.1, GPT-4.1 mini, GPT-4.1 nano
|
||||
# 이전 세대: GPT-4o, GPT-4o mini, GPT-4 Turbo, GPT-3.5 Turbo
|
||||
self.model = "gpt-4o"
|
||||
# 최신 모델: gpt-5-mini
|
||||
self.model = "gpt-5-mini"
|
||||
self.client = AsyncOpenAI(api_key=apikey_settings.CHATGPT_API_KEY)
|
||||
self.customer_name = customer_name
|
||||
self.region = region
|
||||
|
|
@ -248,19 +256,64 @@ class ChatgptService:
|
|||
detail_region_info=self.detail_region_info,
|
||||
)
|
||||
|
||||
async def generate(self, prompt: str | None = None) -> str:
|
||||
"""GPT에게 프롬프트를 전달하여 결과를 반환"""
|
||||
if prompt is None:
|
||||
prompt = self.build_lyrics_prompt()
|
||||
print("Generated Prompt: ", prompt)
|
||||
async def _call_gpt_api(self, prompt: str) -> str:
|
||||
"""GPT API를 직접 호출합니다 (내부 메서드).
|
||||
|
||||
Args:
|
||||
prompt: GPT에 전달할 프롬프트
|
||||
|
||||
Returns:
|
||||
GPT 응답 문자열
|
||||
|
||||
Raises:
|
||||
APIError, APIConnectionError, RateLimitError: OpenAI API 오류
|
||||
"""
|
||||
completion = await self.client.chat.completions.create(
|
||||
model=self.model, messages=[{"role": "user", "content": prompt}]
|
||||
)
|
||||
message = completion.choices[0].message.content
|
||||
return message or ""
|
||||
|
||||
async def generate(
|
||||
self,
|
||||
prompt: str | None = None,
|
||||
) -> str:
|
||||
"""GPT에게 프롬프트를 전달하여 결과를 반환합니다.
|
||||
|
||||
Args:
|
||||
prompt: GPT에 전달할 프롬프트 (None이면 기본 가사 프롬프트 사용)
|
||||
|
||||
Returns:
|
||||
GPT 응답 문자열
|
||||
|
||||
Raises:
|
||||
APIError, APIConnectionError, RateLimitError: OpenAI API 오류
|
||||
"""
|
||||
if prompt is None:
|
||||
prompt = self.build_lyrics_prompt()
|
||||
|
||||
print(f"[ChatgptService] Generated Prompt (length: {len(prompt)})")
|
||||
logger.info(f"[ChatgptService] Starting GPT request with model: {self.model}")
|
||||
|
||||
# GPT API 호출
|
||||
response = await self._call_gpt_api(prompt)
|
||||
|
||||
print(f"[ChatgptService] SUCCESS - Response length: {len(response)}")
|
||||
logger.info(f"[ChatgptService] SUCCESS - Response length: {len(response)}")
|
||||
return response
|
||||
|
||||
async def summarize_marketing(self, text: str) -> str:
|
||||
"""마케팅 텍스트를 항목으로 구분하여 500자로 요약 정리"""
|
||||
"""마케팅 텍스트를 항목으로 구분하여 500자로 요약 정리.
|
||||
|
||||
Args:
|
||||
text: 요약할 마케팅 텍스트
|
||||
|
||||
Returns:
|
||||
요약된 텍스트
|
||||
|
||||
Raises:
|
||||
APIError, APIConnectionError, RateLimitError: OpenAI API 오류
|
||||
"""
|
||||
prompt = f"""[ROLE]
|
||||
마케팅 콘텐츠 요약 전문가
|
||||
|
||||
|
|
@ -281,11 +334,8 @@ class ChatgptService:
|
|||
[항목별로 구분된 500자 이내 요약]
|
||||
---
|
||||
"""
|
||||
completion = await self.client.chat.completions.create(
|
||||
model=self.model, messages=[{"role": "user", "content": prompt}]
|
||||
)
|
||||
message = completion.choices[0].message.content
|
||||
result = message or ""
|
||||
|
||||
result = await self.generate(prompt=prompt)
|
||||
|
||||
# --- 구분자 제거
|
||||
if result.startswith("---"):
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ response = await creatomate.make_creatomate_call(template_id, modifications)
|
|||
"""
|
||||
|
||||
import copy
|
||||
import logging
|
||||
import time
|
||||
from typing import Literal
|
||||
|
||||
|
|
@ -37,6 +38,9 @@ import httpx
|
|||
|
||||
from config import apikey_settings, creatomate_settings
|
||||
|
||||
# 로거 설정
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Orientation 타입 정의
|
||||
OrientationType = Literal["horizontal", "vertical"]
|
||||
|
|
@ -138,11 +142,51 @@ class CreatomateService:
|
|||
"Authorization": f"Bearer {self.api_key}",
|
||||
}
|
||||
|
||||
async def _request(
|
||||
self,
|
||||
method: str,
|
||||
url: str,
|
||||
timeout: float = 30.0,
|
||||
**kwargs,
|
||||
) -> httpx.Response:
|
||||
"""HTTP 요청을 수행합니다.
|
||||
|
||||
Args:
|
||||
method: HTTP 메서드 ("GET", "POST", etc.)
|
||||
url: 요청 URL
|
||||
timeout: 요청 타임아웃 (초)
|
||||
**kwargs: httpx 요청에 전달할 추가 인자
|
||||
|
||||
Returns:
|
||||
httpx.Response: 응답 객체
|
||||
|
||||
Raises:
|
||||
httpx.HTTPError: 요청 실패 시
|
||||
"""
|
||||
logger.info(f"[Creatomate] {method} {url}")
|
||||
print(f"[Creatomate] {method} {url}")
|
||||
|
||||
client = await get_shared_client()
|
||||
|
||||
if method.upper() == "GET":
|
||||
response = await client.get(
|
||||
url, headers=self.headers, timeout=timeout, **kwargs
|
||||
)
|
||||
elif method.upper() == "POST":
|
||||
response = await client.post(
|
||||
url, headers=self.headers, timeout=timeout, **kwargs
|
||||
)
|
||||
else:
|
||||
raise ValueError(f"Unsupported HTTP method: {method}")
|
||||
|
||||
logger.info(f"[Creatomate] Response - Status: {response.status_code}")
|
||||
print(f"[Creatomate] Response - Status: {response.status_code}")
|
||||
return response
|
||||
|
||||
async def get_all_templates_data(self) -> dict:
|
||||
"""모든 템플릿 정보를 조회합니다."""
|
||||
url = f"{self.BASE_URL}/v1/templates"
|
||||
client = await get_shared_client()
|
||||
response = await client.get(url, headers=self.headers, timeout=30.0)
|
||||
response = await self._request("GET", url, timeout=30.0)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
|
|
@ -175,8 +219,7 @@ class CreatomateService:
|
|||
|
||||
# API 호출
|
||||
url = f"{self.BASE_URL}/v1/templates/{template_id}"
|
||||
client = await get_shared_client()
|
||||
response = await client.get(url, headers=self.headers, timeout=30.0)
|
||||
response = await self._request("GET", url, timeout=30.0)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
|
|
@ -331,10 +374,7 @@ class CreatomateService:
|
|||
"template_id": template_id,
|
||||
"modifications": modifications,
|
||||
}
|
||||
client = await get_shared_client()
|
||||
response = await client.post(
|
||||
url, json=data, headers=self.headers, timeout=60.0
|
||||
)
|
||||
response = await self._request("POST", url, timeout=60.0, json=data)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
|
|
@ -345,10 +385,7 @@ class CreatomateService:
|
|||
response에 요청 정보가 있으니 폴링 필요
|
||||
"""
|
||||
url = f"{self.BASE_URL}/v2/renders"
|
||||
client = await get_shared_client()
|
||||
response = await client.post(
|
||||
url, json=source, headers=self.headers, timeout=60.0
|
||||
)
|
||||
response = await self._request("POST", url, timeout=60.0, json=source)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
|
|
@ -379,8 +416,7 @@ class CreatomateService:
|
|||
- failed: 실패
|
||||
"""
|
||||
url = f"{self.BASE_URL}/v1/renders/{render_id}"
|
||||
client = await get_shared_client()
|
||||
response = await client.get(url, headers=self.headers, timeout=30.0)
|
||||
response = await self._request("GET", url, timeout=30.0)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
|
|
|
|||
|
|
@ -1,17 +1,33 @@
|
|||
import json
|
||||
import logging
|
||||
import re
|
||||
|
||||
import aiohttp
|
||||
|
||||
from config import crawler_settings
|
||||
|
||||
# 로거 설정
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class GraphQLException(Exception):
|
||||
"""GraphQL 요청 실패 시 발생하는 예외"""
|
||||
pass
|
||||
|
||||
|
||||
class CrawlingTimeoutException(Exception):
|
||||
"""크롤링 타임아웃 시 발생하는 예외"""
|
||||
pass
|
||||
|
||||
|
||||
class NvMapScraper:
|
||||
"""네이버 지도 GraphQL API 스크래퍼
|
||||
|
||||
네이버 지도에서 숙소/장소 정보를 크롤링합니다.
|
||||
"""
|
||||
|
||||
GRAPHQL_URL: str = "https://pcmap-api.place.naver.com/graphql"
|
||||
REQUEST_TIMEOUT = 30 # 초
|
||||
|
||||
OVERVIEW_QUERY: str = """
|
||||
query getAccommodation($id: String!, $deviceType: String) {
|
||||
|
|
@ -83,25 +99,58 @@ query getAccommodation($id: String!, $deviceType: String) {
|
|||
return
|
||||
|
||||
async def _call_get_accommodation(self, place_id: str) -> dict:
|
||||
"""GraphQL API를 호출하여 숙소 정보를 가져옵니다.
|
||||
|
||||
Args:
|
||||
place_id: 네이버 지도 장소 ID
|
||||
|
||||
Returns:
|
||||
GraphQL 응답 데이터
|
||||
|
||||
Raises:
|
||||
GraphQLException: API 호출 실패 시
|
||||
CrawlingTimeoutException: 타임아웃 발생 시
|
||||
"""
|
||||
payload = {
|
||||
"operationName": "getAccommodation",
|
||||
"variables": {"id": place_id, "deviceType": "pc"},
|
||||
"query": self.OVERVIEW_QUERY,
|
||||
}
|
||||
json_payload = json.dumps(payload)
|
||||
timeout = aiohttp.ClientTimeout(total=self.REQUEST_TIMEOUT)
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
try:
|
||||
logger.info(f"[NvMapScraper] Requesting place_id: {place_id}")
|
||||
print(f"[NvMapScraper] Requesting place_id: {place_id}")
|
||||
|
||||
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||||
async with session.post(
|
||||
self.GRAPHQL_URL, data=json_payload, headers=self._get_request_headers()
|
||||
self.GRAPHQL_URL,
|
||||
data=json_payload,
|
||||
headers=self._get_request_headers()
|
||||
) as response:
|
||||
if response.status == 200:
|
||||
logger.info(f"[NvMapScraper] SUCCESS - place_id: {place_id}")
|
||||
print(f"[NvMapScraper] SUCCESS - place_id: {place_id}")
|
||||
return await response.json()
|
||||
else:
|
||||
print("실패 상태 코드:", response.status)
|
||||
|
||||
# 실패 상태 코드
|
||||
logger.error(f"[NvMapScraper] Failed with status {response.status} - place_id: {place_id}")
|
||||
print(f"[NvMapScraper] 실패 상태 코드: {response.status}")
|
||||
raise GraphQLException(
|
||||
f"Request failed with status {response.status}"
|
||||
)
|
||||
|
||||
except TimeoutError:
|
||||
logger.error(f"[NvMapScraper] Timeout - place_id: {place_id}")
|
||||
print(f"[NvMapScraper] Timeout - place_id: {place_id}")
|
||||
raise CrawlingTimeoutException(f"Request timed out after {self.REQUEST_TIMEOUT}s")
|
||||
|
||||
except aiohttp.ClientError as e:
|
||||
logger.error(f"[NvMapScraper] Client error: {e}")
|
||||
print(f"[NvMapScraper] Client error: {e}")
|
||||
raise GraphQLException(f"Client error: {e}")
|
||||
|
||||
|
||||
# if __name__ == "__main__":
|
||||
# import asyncio
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ URL 경로 형식:
|
|||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
|
|
@ -40,6 +41,8 @@ import httpx
|
|||
|
||||
from config import azure_blob_settings
|
||||
|
||||
# 로거 설정
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# =============================================================================
|
||||
# 모듈 레벨 공유 HTTP 클라이언트 (싱글톤 패턴)
|
||||
|
|
@ -138,17 +141,30 @@ class AzureBlobUploader:
|
|||
timeout: float,
|
||||
log_prefix: str,
|
||||
) -> bool:
|
||||
"""바이트 데이터를 업로드하는 공통 내부 메서드"""
|
||||
"""바이트 데이터를 업로드하는 공통 내부 메서드
|
||||
|
||||
Args:
|
||||
file_content: 업로드할 바이트 데이터
|
||||
upload_url: 업로드 URL
|
||||
headers: HTTP 헤더
|
||||
timeout: 요청 타임아웃 (초)
|
||||
log_prefix: 로그 접두사
|
||||
|
||||
Returns:
|
||||
bool: 업로드 성공 여부
|
||||
"""
|
||||
size = len(file_content)
|
||||
start_time = time.perf_counter()
|
||||
|
||||
try:
|
||||
logger.info(f"[{log_prefix}] Starting upload")
|
||||
print(f"[{log_prefix}] Getting shared client...")
|
||||
|
||||
client = await get_shared_blob_client()
|
||||
client_time = time.perf_counter()
|
||||
elapsed_ms = (client_time - start_time) * 1000
|
||||
print(f"[{log_prefix}] Client acquired in {elapsed_ms:.1f}ms")
|
||||
|
||||
size = len(file_content)
|
||||
print(f"[{log_prefix}] Starting upload... "
|
||||
f"(size: {size} bytes, timeout: {timeout}s)")
|
||||
|
||||
|
|
@ -160,11 +176,14 @@ class AzureBlobUploader:
|
|||
duration_ms = (upload_time - start_time) * 1000
|
||||
|
||||
if response.status_code in [200, 201]:
|
||||
logger.info(f"[{log_prefix}] SUCCESS - Status: {response.status_code}")
|
||||
print(f"[{log_prefix}] SUCCESS - Status: {response.status_code}, "
|
||||
f"Duration: {duration_ms:.1f}ms")
|
||||
print(f"[{log_prefix}] Public URL: {self._last_public_url}")
|
||||
return True
|
||||
else:
|
||||
|
||||
# 업로드 실패
|
||||
logger.error(f"[{log_prefix}] FAILED - Status: {response.status_code}")
|
||||
print(f"[{log_prefix}] FAILED - Status: {response.status_code}, "
|
||||
f"Duration: {duration_ms:.1f}ms")
|
||||
print(f"[{log_prefix}] Response: {response.text[:500]}")
|
||||
|
|
@ -172,21 +191,27 @@ class AzureBlobUploader:
|
|||
|
||||
except asyncio.TimeoutError:
|
||||
elapsed = time.perf_counter() - start_time
|
||||
print(f"[{log_prefix}] TIMEOUT after {elapsed:.1f}s "
|
||||
f"(limit: {timeout}s)")
|
||||
logger.error(f"[{log_prefix}] TIMEOUT after {elapsed:.1f}s")
|
||||
print(f"[{log_prefix}] TIMEOUT after {elapsed:.1f}s")
|
||||
return False
|
||||
|
||||
except httpx.ConnectError as e:
|
||||
elapsed = time.perf_counter() - start_time
|
||||
logger.error(f"[{log_prefix}] CONNECT_ERROR: {e}")
|
||||
print(f"[{log_prefix}] CONNECT_ERROR after {elapsed:.1f}s - "
|
||||
f"{type(e).__name__}: {e}")
|
||||
return False
|
||||
|
||||
except httpx.ReadError as e:
|
||||
elapsed = time.perf_counter() - start_time
|
||||
logger.error(f"[{log_prefix}] READ_ERROR: {e}")
|
||||
print(f"[{log_prefix}] READ_ERROR after {elapsed:.1f}s - "
|
||||
f"{type(e).__name__}: {e}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
elapsed = time.perf_counter() - start_time
|
||||
logger.error(f"[{log_prefix}] ERROR: {type(e).__name__}: {e}")
|
||||
print(f"[{log_prefix}] ERROR after {elapsed:.1f}s - "
|
||||
f"{type(e).__name__}: {e}")
|
||||
return False
|
||||
|
|
|
|||
|
|
@ -783,7 +783,7 @@ async def make_automation(request: Request, conn: Connection):
|
|||
else "",
|
||||
"attr_value": ", ".join(selected_values) if selected_values else "",
|
||||
"ai": "ChatGPT",
|
||||
"ai_model": "gpt-4o",
|
||||
"ai_model": "gpt-5-mini",
|
||||
"genre": "후크송",
|
||||
"sample_song": combined_sample_song or "없음",
|
||||
"result_song": final_lyrics,
|
||||
|
|
|
|||
|
|
@ -4,16 +4,109 @@ Video Background Tasks
|
|||
영상 생성 관련 백그라운드 태스크를 정의합니다.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import traceback
|
||||
from pathlib import Path
|
||||
|
||||
import aiofiles
|
||||
import httpx
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
|
||||
from app.database.session import BackgroundSessionLocal
|
||||
from app.video.models import Video
|
||||
from app.utils.upload_blob_as_request import AzureBlobUploader
|
||||
|
||||
# 로거 설정
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# HTTP 요청 설정
|
||||
REQUEST_TIMEOUT = 300.0 # 초 (영상은 용량이 크므로 5분)
|
||||
|
||||
|
||||
async def _update_video_status(
|
||||
task_id: str,
|
||||
status: str,
|
||||
video_url: str | None = None,
|
||||
creatomate_render_id: str | None = None,
|
||||
) -> bool:
|
||||
"""Video 테이블의 상태를 업데이트합니다.
|
||||
|
||||
Args:
|
||||
task_id: 프로젝트 task_id
|
||||
status: 변경할 상태 ("processing", "completed", "failed")
|
||||
video_url: 영상 URL
|
||||
creatomate_render_id: Creatomate render ID (선택)
|
||||
|
||||
Returns:
|
||||
bool: 업데이트 성공 여부
|
||||
"""
|
||||
try:
|
||||
async with BackgroundSessionLocal() as session:
|
||||
if creatomate_render_id:
|
||||
query_result = await session.execute(
|
||||
select(Video)
|
||||
.where(Video.creatomate_render_id == creatomate_render_id)
|
||||
.order_by(Video.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
else:
|
||||
query_result = await session.execute(
|
||||
select(Video)
|
||||
.where(Video.task_id == task_id)
|
||||
.order_by(Video.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
|
||||
video = query_result.scalar_one_or_none()
|
||||
|
||||
if video:
|
||||
video.status = status
|
||||
if video_url is not None:
|
||||
video.result_movie_url = video_url
|
||||
await session.commit()
|
||||
logger.info(f"[Video] Status updated - task_id: {task_id}, status: {status}")
|
||||
print(f"[Video] Status updated - task_id: {task_id}, status: {status}")
|
||||
return True
|
||||
else:
|
||||
logger.warning(f"[Video] NOT FOUND in DB - task_id: {task_id}")
|
||||
print(f"[Video] NOT FOUND in DB - task_id: {task_id}")
|
||||
return False
|
||||
|
||||
except SQLAlchemyError as e:
|
||||
logger.error(f"[Video] DB Error while updating status - task_id: {task_id}, error: {e}")
|
||||
print(f"[Video] DB Error while updating status - task_id: {task_id}, error: {e}")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"[Video] Unexpected error while updating status - task_id: {task_id}, error: {e}")
|
||||
print(f"[Video] Unexpected error while updating status - task_id: {task_id}, error: {e}")
|
||||
return False
|
||||
|
||||
|
||||
async def _download_video(url: str, task_id: str) -> bytes:
|
||||
"""URL에서 영상을 다운로드합니다.
|
||||
|
||||
Args:
|
||||
url: 다운로드할 URL
|
||||
task_id: 로그용 task_id
|
||||
|
||||
Returns:
|
||||
bytes: 다운로드한 파일 내용
|
||||
|
||||
Raises:
|
||||
httpx.HTTPError: 다운로드 실패 시
|
||||
"""
|
||||
logger.info(f"[VideoDownload] Downloading - task_id: {task_id}")
|
||||
print(f"[VideoDownload] Downloading - task_id: {task_id}")
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(url, timeout=REQUEST_TIMEOUT)
|
||||
response.raise_for_status()
|
||||
|
||||
logger.info(f"[VideoDownload] SUCCESS - task_id: {task_id}, size: {len(response.content)} bytes")
|
||||
print(f"[VideoDownload] SUCCESS - task_id: {task_id}, size: {len(response.content)} bytes")
|
||||
return response.content
|
||||
|
||||
|
||||
async def download_and_upload_video_to_blob(
|
||||
task_id: str,
|
||||
|
|
@ -27,6 +120,7 @@ async def download_and_upload_video_to_blob(
|
|||
video_url: 다운로드할 영상 URL
|
||||
store_name: 저장할 파일명에 사용할 업체명
|
||||
"""
|
||||
logger.info(f"[download_and_upload_video_to_blob] START - task_id: {task_id}, store_name: {store_name}")
|
||||
print(f"[download_and_upload_video_to_blob] START - task_id: {task_id}, store_name: {store_name}")
|
||||
temp_file_path: Path | None = None
|
||||
|
||||
|
|
@ -42,16 +136,19 @@ async def download_and_upload_video_to_blob(
|
|||
temp_dir = Path("media") / "temp" / task_id
|
||||
temp_dir.mkdir(parents=True, exist_ok=True)
|
||||
temp_file_path = temp_dir / file_name
|
||||
logger.info(f"[download_and_upload_video_to_blob] Temp directory created - path: {temp_file_path}")
|
||||
print(f"[download_and_upload_video_to_blob] Temp directory created - path: {temp_file_path}")
|
||||
|
||||
# 영상 파일 다운로드
|
||||
logger.info(f"[download_and_upload_video_to_blob] Downloading video - task_id: {task_id}, url: {video_url}")
|
||||
print(f"[download_and_upload_video_to_blob] Downloading video - task_id: {task_id}, url: {video_url}")
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(video_url, timeout=180.0)
|
||||
response.raise_for_status()
|
||||
|
||||
content = await _download_video(video_url, task_id)
|
||||
|
||||
async with aiofiles.open(str(temp_file_path), "wb") as f:
|
||||
await f.write(response.content)
|
||||
await f.write(content)
|
||||
|
||||
logger.info(f"[download_and_upload_video_to_blob] File downloaded - task_id: {task_id}, path: {temp_file_path}")
|
||||
print(f"[download_and_upload_video_to_blob] File downloaded - task_id: {task_id}, path: {temp_file_path}")
|
||||
|
||||
# Azure Blob Storage에 업로드
|
||||
|
|
@ -63,51 +160,41 @@ async def download_and_upload_video_to_blob(
|
|||
|
||||
# SAS 토큰이 제외된 public_url 사용
|
||||
blob_url = uploader.public_url
|
||||
logger.info(f"[download_and_upload_video_to_blob] Uploaded to Blob - task_id: {task_id}, url: {blob_url}")
|
||||
print(f"[download_and_upload_video_to_blob] Uploaded to Blob - task_id: {task_id}, url: {blob_url}")
|
||||
|
||||
# Video 테이블 업데이트 (새 세션 사용)
|
||||
async with BackgroundSessionLocal() as session:
|
||||
# 여러 개 있을 경우 가장 최근 것 선택
|
||||
result = await session.execute(
|
||||
select(Video)
|
||||
.where(Video.task_id == task_id)
|
||||
.order_by(Video.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
video = result.scalar_one_or_none()
|
||||
# Video 테이블 업데이트
|
||||
await _update_video_status(task_id, "completed", blob_url)
|
||||
logger.info(f"[download_and_upload_video_to_blob] SUCCESS - task_id: {task_id}")
|
||||
print(f"[download_and_upload_video_to_blob] SUCCESS - task_id: {task_id}")
|
||||
|
||||
if video:
|
||||
video.status = "completed"
|
||||
video.result_movie_url = blob_url
|
||||
await session.commit()
|
||||
print(f"[download_and_upload_video_to_blob] SUCCESS - task_id: {task_id}, status: completed")
|
||||
else:
|
||||
print(f"[download_and_upload_video_to_blob] Video NOT FOUND in DB - task_id: {task_id}")
|
||||
except httpx.HTTPError as e:
|
||||
logger.error(f"[download_and_upload_video_to_blob] DOWNLOAD ERROR - task_id: {task_id}, error: {e}")
|
||||
print(f"[download_and_upload_video_to_blob] DOWNLOAD ERROR - task_id: {task_id}, error: {e}")
|
||||
traceback.print_exc()
|
||||
await _update_video_status(task_id, "failed")
|
||||
|
||||
except SQLAlchemyError as e:
|
||||
logger.error(f"[download_and_upload_video_to_blob] DB ERROR - task_id: {task_id}, error: {e}")
|
||||
print(f"[download_and_upload_video_to_blob] DB ERROR - task_id: {task_id}, error: {e}")
|
||||
traceback.print_exc()
|
||||
await _update_video_status(task_id, "failed")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[download_and_upload_video_to_blob] EXCEPTION - task_id: {task_id}, error: {e}")
|
||||
print(f"[download_and_upload_video_to_blob] EXCEPTION - task_id: {task_id}, error: {e}")
|
||||
# 실패 시 Video 테이블 업데이트
|
||||
async with BackgroundSessionLocal() as session:
|
||||
result = await session.execute(
|
||||
select(Video)
|
||||
.where(Video.task_id == task_id)
|
||||
.order_by(Video.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
video = result.scalar_one_or_none()
|
||||
|
||||
if video:
|
||||
video.status = "failed"
|
||||
await session.commit()
|
||||
print(f"[download_and_upload_video_to_blob] FAILED - task_id: {task_id}, status updated to failed")
|
||||
traceback.print_exc()
|
||||
await _update_video_status(task_id, "failed")
|
||||
|
||||
finally:
|
||||
# 임시 파일 삭제
|
||||
if temp_file_path and temp_file_path.exists():
|
||||
try:
|
||||
temp_file_path.unlink()
|
||||
logger.info(f"[download_and_upload_video_to_blob] Temp file deleted - path: {temp_file_path}")
|
||||
print(f"[download_and_upload_video_to_blob] Temp file deleted - path: {temp_file_path}")
|
||||
except Exception as e:
|
||||
logger.warning(f"[download_and_upload_video_to_blob] Failed to delete temp file: {e}")
|
||||
print(f"[download_and_upload_video_to_blob] Failed to delete temp file: {e}")
|
||||
|
||||
# 임시 디렉토리 삭제 시도
|
||||
|
|
@ -131,6 +218,7 @@ async def download_and_upload_video_by_creatomate_render_id(
|
|||
video_url: 다운로드할 영상 URL
|
||||
store_name: 저장할 파일명에 사용할 업체명
|
||||
"""
|
||||
logger.info(f"[download_and_upload_video_by_creatomate_render_id] START - creatomate_render_id: {creatomate_render_id}, store_name: {store_name}")
|
||||
print(f"[download_and_upload_video_by_creatomate_render_id] START - creatomate_render_id: {creatomate_render_id}, store_name: {store_name}")
|
||||
temp_file_path: Path | None = None
|
||||
task_id: str | None = None
|
||||
|
|
@ -147,10 +235,12 @@ async def download_and_upload_video_by_creatomate_render_id(
|
|||
video = result.scalar_one_or_none()
|
||||
|
||||
if not video:
|
||||
logger.warning(f"[download_and_upload_video_by_creatomate_render_id] Video NOT FOUND - creatomate_render_id: {creatomate_render_id}")
|
||||
print(f"[download_and_upload_video_by_creatomate_render_id] Video NOT FOUND - creatomate_render_id: {creatomate_render_id}")
|
||||
return
|
||||
|
||||
task_id = video.task_id
|
||||
logger.info(f"[download_and_upload_video_by_creatomate_render_id] Video found - creatomate_render_id: {creatomate_render_id}, task_id: {task_id}")
|
||||
print(f"[download_and_upload_video_by_creatomate_render_id] Video found - creatomate_render_id: {creatomate_render_id}, task_id: {task_id}")
|
||||
|
||||
# 파일명에 사용할 수 없는 문자 제거
|
||||
|
|
@ -164,16 +254,19 @@ async def download_and_upload_video_by_creatomate_render_id(
|
|||
temp_dir = Path("media") / "temp" / task_id
|
||||
temp_dir.mkdir(parents=True, exist_ok=True)
|
||||
temp_file_path = temp_dir / file_name
|
||||
logger.info(f"[download_and_upload_video_by_creatomate_render_id] Temp directory created - path: {temp_file_path}")
|
||||
print(f"[download_and_upload_video_by_creatomate_render_id] Temp directory created - path: {temp_file_path}")
|
||||
|
||||
# 영상 파일 다운로드
|
||||
logger.info(f"[download_and_upload_video_by_creatomate_render_id] Downloading video - creatomate_render_id: {creatomate_render_id}, url: {video_url}")
|
||||
print(f"[download_and_upload_video_by_creatomate_render_id] Downloading video - creatomate_render_id: {creatomate_render_id}, url: {video_url}")
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(video_url, timeout=180.0)
|
||||
response.raise_for_status()
|
||||
|
||||
content = await _download_video(video_url, task_id)
|
||||
|
||||
async with aiofiles.open(str(temp_file_path), "wb") as f:
|
||||
await f.write(response.content)
|
||||
await f.write(content)
|
||||
|
||||
logger.info(f"[download_and_upload_video_by_creatomate_render_id] File downloaded - creatomate_render_id: {creatomate_render_id}, path: {temp_file_path}")
|
||||
print(f"[download_and_upload_video_by_creatomate_render_id] File downloaded - creatomate_render_id: {creatomate_render_id}, path: {temp_file_path}")
|
||||
|
||||
# Azure Blob Storage에 업로드
|
||||
|
|
@ -185,51 +278,49 @@ async def download_and_upload_video_by_creatomate_render_id(
|
|||
|
||||
# SAS 토큰이 제외된 public_url 사용
|
||||
blob_url = uploader.public_url
|
||||
logger.info(f"[download_and_upload_video_by_creatomate_render_id] Uploaded to Blob - creatomate_render_id: {creatomate_render_id}, url: {blob_url}")
|
||||
print(f"[download_and_upload_video_by_creatomate_render_id] Uploaded to Blob - creatomate_render_id: {creatomate_render_id}, url: {blob_url}")
|
||||
|
||||
# Video 테이블 업데이트 (새 세션 사용)
|
||||
async with BackgroundSessionLocal() as session:
|
||||
result = await session.execute(
|
||||
select(Video)
|
||||
.where(Video.creatomate_render_id == creatomate_render_id)
|
||||
.order_by(Video.created_at.desc())
|
||||
.limit(1)
|
||||
# Video 테이블 업데이트
|
||||
await _update_video_status(
|
||||
task_id=task_id,
|
||||
status="completed",
|
||||
video_url=blob_url,
|
||||
creatomate_render_id=creatomate_render_id,
|
||||
)
|
||||
video = result.scalar_one_or_none()
|
||||
logger.info(f"[download_and_upload_video_by_creatomate_render_id] SUCCESS - creatomate_render_id: {creatomate_render_id}")
|
||||
print(f"[download_and_upload_video_by_creatomate_render_id] SUCCESS - creatomate_render_id: {creatomate_render_id}")
|
||||
|
||||
if video:
|
||||
video.status = "completed"
|
||||
video.result_movie_url = blob_url
|
||||
await session.commit()
|
||||
print(f"[download_and_upload_video_by_creatomate_render_id] SUCCESS - creatomate_render_id: {creatomate_render_id}, status: completed")
|
||||
else:
|
||||
print(f"[download_and_upload_video_by_creatomate_render_id] Video NOT FOUND in DB - creatomate_render_id: {creatomate_render_id}")
|
||||
except httpx.HTTPError as e:
|
||||
logger.error(f"[download_and_upload_video_by_creatomate_render_id] DOWNLOAD ERROR - creatomate_render_id: {creatomate_render_id}, error: {e}")
|
||||
print(f"[download_and_upload_video_by_creatomate_render_id] DOWNLOAD ERROR - creatomate_render_id: {creatomate_render_id}, error: {e}")
|
||||
traceback.print_exc()
|
||||
if task_id:
|
||||
await _update_video_status(task_id, "failed", creatomate_render_id=creatomate_render_id)
|
||||
|
||||
except SQLAlchemyError as e:
|
||||
logger.error(f"[download_and_upload_video_by_creatomate_render_id] DB ERROR - creatomate_render_id: {creatomate_render_id}, error: {e}")
|
||||
print(f"[download_and_upload_video_by_creatomate_render_id] DB ERROR - creatomate_render_id: {creatomate_render_id}, error: {e}")
|
||||
traceback.print_exc()
|
||||
if task_id:
|
||||
await _update_video_status(task_id, "failed", creatomate_render_id=creatomate_render_id)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[download_and_upload_video_by_creatomate_render_id] EXCEPTION - creatomate_render_id: {creatomate_render_id}, error: {e}")
|
||||
print(f"[download_and_upload_video_by_creatomate_render_id] EXCEPTION - creatomate_render_id: {creatomate_render_id}, error: {e}")
|
||||
# 실패 시 Video 테이블 업데이트
|
||||
traceback.print_exc()
|
||||
if task_id:
|
||||
async with BackgroundSessionLocal() as session:
|
||||
result = await session.execute(
|
||||
select(Video)
|
||||
.where(Video.creatomate_render_id == creatomate_render_id)
|
||||
.order_by(Video.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
video = result.scalar_one_or_none()
|
||||
|
||||
if video:
|
||||
video.status = "failed"
|
||||
await session.commit()
|
||||
print(f"[download_and_upload_video_by_creatomate_render_id] FAILED - creatomate_render_id: {creatomate_render_id}, status updated to failed")
|
||||
await _update_video_status(task_id, "failed", creatomate_render_id=creatomate_render_id)
|
||||
|
||||
finally:
|
||||
# 임시 파일 삭제
|
||||
if temp_file_path and temp_file_path.exists():
|
||||
try:
|
||||
temp_file_path.unlink()
|
||||
logger.info(f"[download_and_upload_video_by_creatomate_render_id] Temp file deleted - path: {temp_file_path}")
|
||||
print(f"[download_and_upload_video_by_creatomate_render_id] Temp file deleted - path: {temp_file_path}")
|
||||
except Exception as e:
|
||||
logger.warning(f"[download_and_upload_video_by_creatomate_render_id] Failed to delete temp file: {e}")
|
||||
print(f"[download_and_upload_video_by_creatomate_render_id] Failed to delete temp file: {e}")
|
||||
|
||||
# 임시 디렉토리 삭제 시도
|
||||
|
|
|
|||
|
|
@ -31,7 +31,6 @@ O2O-CASTAD Backend는 FastAPI 기반의 비동기 백엔드 서비스로, AI 기
|
|||
|
||||
| 영역 | 개선 전 | 개선 후 | 개선율 |
|
||||
|------|---------|---------|--------|
|
||||
| DB 쿼리 실행 | 순차 (200ms) | 병렬 (55ms) | **72% 감소** |
|
||||
| 템플릿 API 호출 | 매번 호출 (1-2s) | 캐시 HIT (0ms) | **100% 감소** |
|
||||
| HTTP 클라이언트 | 매번 생성 (50ms) | 풀 재사용 (0ms) | **100% 감소** |
|
||||
| 세션 타임아웃 에러 | 빈번 | 해결 | **안정성 확보** |
|
||||
|
|
@ -41,7 +40,7 @@ O2O-CASTAD Backend는 FastAPI 기반의 비동기 백엔드 서비스로, AI 기
|
|||
1. **이중 커넥션 풀 아키텍처**: 요청/백그라운드 분리
|
||||
2. **명시적 세션 라이프사이클**: 외부 API 호출 전 세션 해제
|
||||
3. **모듈 레벨 싱글톤**: HTTP 클라이언트 및 템플릿 캐시
|
||||
4. **asyncio.gather() 기반 병렬 쿼리**: 다중 테이블 동시 조회
|
||||
4. **순차 쿼리 실행**: AsyncSession 제약으로 단일 세션 내 병렬 쿼리 불가
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -78,7 +77,7 @@ engine = create_async_engine(
|
|||
pool_size=20, # 기본 풀 크기
|
||||
max_overflow=20, # 추가 연결 허용
|
||||
pool_timeout=30, # 연결 대기 최대 시간
|
||||
pool_recycle=3600, # 1시간마다 연결 재생성
|
||||
pool_recycle=280, # MySQL wait_timeout 보다 짧게 설정
|
||||
pool_pre_ping=True, # 연결 유효성 검사 (핵심!)
|
||||
pool_reset_on_return="rollback", # 반환 시 롤백
|
||||
)
|
||||
|
|
@ -89,8 +88,9 @@ background_engine = create_async_engine(
|
|||
pool_size=10, # 더 작은 풀
|
||||
max_overflow=10,
|
||||
pool_timeout=60, # 백그라운드는 대기 여유
|
||||
pool_recycle=3600,
|
||||
pool_recycle=280, # MySQL wait_timeout 보다 짧게 설정
|
||||
pool_pre_ping=True,
|
||||
pool_reset_on_return="rollback", # 반환 시 롤백
|
||||
)
|
||||
```
|
||||
|
||||
|
|
@ -120,8 +120,10 @@ async def get_item(
|
|||
async def generate_video(task_id: str):
|
||||
# 1단계: 명시적 세션 열기 → DB 작업 → 세션 닫기
|
||||
async with AsyncSessionLocal() as session:
|
||||
# 병렬 쿼리 실행
|
||||
results = await asyncio.gather(...)
|
||||
# 순차 쿼리 실행 (AsyncSession은 병렬 쿼리 미지원)
|
||||
project = await session.execute(select(Project).where(...))
|
||||
lyric = await session.execute(select(Lyric).where(...))
|
||||
song = await session.execute(select(Song).where(...))
|
||||
# 초기 데이터 저장
|
||||
await session.commit()
|
||||
# 세션 닫힘 (async with 블록 종료)
|
||||
|
|
@ -193,38 +195,47 @@ async def generate_video():
|
|||
|
||||
## 3. 비동기 처리 패턴
|
||||
|
||||
### 3.1 asyncio.gather() 병렬 쿼리
|
||||
### 3.1 순차 쿼리 실행 (AsyncSession 제약)
|
||||
|
||||
**위치**: `app/video/api/routers/v1/video.py`
|
||||
|
||||
> **중요**: SQLAlchemy AsyncSession은 단일 세션에서 동시에 여러 쿼리를 실행하는 것을 지원하지 않습니다.
|
||||
> `asyncio.gather()`로 병렬 쿼리를 실행하면 세션 상태 충돌이 발생합니다.
|
||||
|
||||
```python
|
||||
# 4개의 독립적인 쿼리를 병렬로 실행
|
||||
project_result, lyric_result, song_result, image_result = (
|
||||
await asyncio.gather(
|
||||
session.execute(project_query),
|
||||
session.execute(lyric_query),
|
||||
session.execute(song_query),
|
||||
session.execute(image_query),
|
||||
# 순차 쿼리 실행: Project, Lyric, Song, Image
|
||||
# Note: AsyncSession은 동일 세션에서 병렬 쿼리를 지원하지 않음
|
||||
|
||||
# Project 조회
|
||||
project_result = await session.execute(
|
||||
select(Project).where(Project.task_id == task_id)
|
||||
.order_by(Project.created_at.desc()).limit(1)
|
||||
)
|
||||
|
||||
# Lyric 조회
|
||||
lyric_result = await session.execute(
|
||||
select(Lyric).where(Lyric.task_id == task_id)
|
||||
.order_by(Lyric.created_at.desc()).limit(1)
|
||||
)
|
||||
|
||||
# Song 조회
|
||||
song_result = await session.execute(
|
||||
select(Song).where(Song.task_id == task_id)
|
||||
.order_by(Song.created_at.desc()).limit(1)
|
||||
)
|
||||
|
||||
# Image 조회
|
||||
image_result = await session.execute(
|
||||
select(Image).where(Image.task_id == task_id)
|
||||
.order_by(Image.img_order.asc())
|
||||
)
|
||||
```
|
||||
|
||||
**성능 비교:**
|
||||
```
|
||||
[순차 실행]
|
||||
Query 1 ──────▶ 50ms
|
||||
Query 2 ──────▶ 50ms
|
||||
Query 3 ──────▶ 50ms
|
||||
Query 4 ──────▶ 50ms
|
||||
총 소요시간: 200ms
|
||||
**AsyncSession 병렬 쿼리 제약 사항:**
|
||||
|
||||
[병렬 실행]
|
||||
Query 1 ──────▶ 50ms
|
||||
Query 2 ──────▶ 50ms
|
||||
Query 3 ──────▶ 50ms
|
||||
Query 4 ──────▶ 50ms
|
||||
총 소요시간: ~55ms (가장 느린 쿼리 + 오버헤드)
|
||||
```
|
||||
- 단일 세션 내에서 `asyncio.gather()`로 여러 쿼리 동시 실행 불가
|
||||
- 세션 상태 충돌 및 예기치 않은 동작 발생 가능
|
||||
- 병렬 쿼리가 필요한 경우 별도의 세션을 각각 생성해야 함
|
||||
|
||||
### 3.2 FastAPI BackgroundTasks 활용
|
||||
|
||||
|
|
@ -553,7 +564,6 @@ query = (
|
|||
|
||||
| 요소 | 구현 | 효과 |
|
||||
|------|------|------|
|
||||
| asyncio.gather() | 병렬 쿼리 | 72% 응답 시간 단축 |
|
||||
| 템플릿 캐싱 | TTL 기반 메모리 캐시 | API 호출 100% 감소 |
|
||||
| HTTP 클라이언트 풀 | 싱글톤 패턴 | 커넥션 재사용 |
|
||||
| N+1 해결 | IN 절 배치 조회 | 쿼리 수 N→2 감소 |
|
||||
|
|
@ -721,7 +731,7 @@ async def generate_video_with_lock(task_id: str):
|
|||
5. 영상 생성
|
||||
Client ─▶ GET /video/generate/{task_id}
|
||||
│
|
||||
├─ asyncio.gather() ─▶ DB(Project, Lyric, Song, Image)
|
||||
├─ 순차 쿼리 ─▶ DB(Project, Lyric, Song, Image)
|
||||
│
|
||||
├─ Creatomate API ─▶ render_id
|
||||
│
|
||||
|
|
@ -750,7 +760,7 @@ async def generate_video_with_lock(task_id: str):
|
|||
O2O-CASTAD Backend는 **프로덕션 준비 수준의 비동기 아키텍처**를 갖추고 있습니다:
|
||||
|
||||
1. **안정성**: 이중 커넥션 풀, pool_pre_ping, 명시적 세션 관리로 런타임 에러 최소화
|
||||
2. **성능**: 병렬 쿼리, 캐싱, HTTP 클라이언트 풀링으로 응답 시간 최적화
|
||||
2. **성능**: 캐싱, HTTP 클라이언트 풀링으로 응답 시간 최적화
|
||||
3. **확장성**: 백그라운드 태스크 분리, 폴링 패턴으로 부하 분산
|
||||
4. **유지보수성**: 일관된 패턴, 구조화된 로깅, 타입 힌트
|
||||
|
||||
|
|
@ -761,7 +771,6 @@ O2O-CASTAD Backend는 **프로덕션 준비 수준의 비동기 아키텍처**
|
|||
│ BEFORE → AFTER │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ Session Timeout Errors │ Frequent → Resolved │
|
||||
│ DB Query Time │ 200ms → 55ms (72%↓) │
|
||||
│ Template API Calls │ Every req → Cached (100%↓) │
|
||||
│ HTTP Client Overhead │ 50ms/req → 0ms (100%↓) │
|
||||
│ N+1 Query Problem │ N queries → 2 queries │
|
||||
|
|
|
|||
|
|
@ -125,7 +125,7 @@ Generate lyrics now:""")
|
|||
])
|
||||
|
||||
# 체인 구성
|
||||
lyric_chain = LYRIC_PROMPT | ChatOpenAI(model="gpt-4o-mini") | StrOutputParser()
|
||||
lyric_chain = LYRIC_PROMPT | ChatOpenAI(model="gpt-5-mini-mini") | StrOutputParser()
|
||||
|
||||
# 사용
|
||||
result = await lyric_chain.ainvoke({
|
||||
|
|
@ -234,7 +234,7 @@ parser = PydanticOutputParser(pydantic_object=LyricOutput)
|
|||
# 자동 수정 파서 (파싱 실패 시 LLM으로 재시도)
|
||||
fixing_parser = OutputFixingParser.from_llm(
|
||||
parser=parser,
|
||||
llm=ChatOpenAI(model="gpt-4o-mini")
|
||||
llm=ChatOpenAI(model="gpt-5-mini-mini")
|
||||
)
|
||||
|
||||
# 프롬프트에 포맷 지시 추가
|
||||
|
|
@ -1034,7 +1034,7 @@ async def generate_lyrics_with_rag(
|
|||
""")
|
||||
])
|
||||
|
||||
chain = rag_prompt | ChatOpenAI(model="gpt-4o") | StrOutputParser()
|
||||
chain = rag_prompt | ChatOpenAI(model="gpt-5-mini") | StrOutputParser()
|
||||
|
||||
result = await chain.ainvoke({
|
||||
"examples": examples_text,
|
||||
|
|
@ -1128,7 +1128,7 @@ async def enrich_lyrics_with_region_info(
|
|||
from langchain_openai import ChatOpenAI
|
||||
|
||||
# Vision 모델로 이미지 분석
|
||||
vision_model = ChatOpenAI(model="gpt-4o")
|
||||
vision_model = ChatOpenAI(model="gpt-5-mini")
|
||||
|
||||
# 이미지 분석 문서 구조
|
||||
class ImageAnalysis(BaseModel):
|
||||
|
|
@ -1151,7 +1151,7 @@ image_store = Chroma(
|
|||
async def analyze_and_store_image(image_url: str, task_id: str):
|
||||
"""이미지 분석 후 벡터 스토어에 저장"""
|
||||
|
||||
# GPT-4o Vision으로 이미지 분석
|
||||
# gpt-5-mini Vision으로 이미지 분석
|
||||
analysis_response = await vision_model.ainvoke([
|
||||
{
|
||||
"type": "text",
|
||||
|
|
|
|||
|
|
@ -801,7 +801,7 @@ from app.infrastructure.external.chatgpt.prompts import LYRICS_PROMPT_TEMPLATE
|
|||
class ChatGPTClient(ILLMClient):
|
||||
"""ChatGPT 클라이언트 구현"""
|
||||
|
||||
def __init__(self, api_key: str, model: str = "gpt-4o"):
|
||||
def __init__(self, api_key: str, model: str = "gpt-5-mini"):
|
||||
self._client = AsyncOpenAI(api_key=api_key)
|
||||
self._model = model
|
||||
|
||||
|
|
@ -956,7 +956,7 @@ def get_chatgpt_client(
|
|||
) -> ChatGPTClient:
|
||||
return ChatGPTClient(
|
||||
api_key=settings.CHATGPT_API_KEY,
|
||||
model="gpt-4o"
|
||||
model="gpt-5-mini"
|
||||
)
|
||||
|
||||
def get_suno_client(
|
||||
|
|
|
|||
|
After Width: | Height: | Size: 191 KiB |
|
After Width: | Height: | Size: 94 KiB |
|
After Width: | Height: | Size: 152 KiB |
|
After Width: | Height: | Size: 191 KiB |
|
After Width: | Height: | Size: 94 KiB |
|
After Width: | Height: | Size: 152 KiB |
|
After Width: | Height: | Size: 191 KiB |
|
After Width: | Height: | Size: 94 KiB |
|
After Width: | Height: | Size: 152 KiB |
|
After Width: | Height: | Size: 191 KiB |
|
After Width: | Height: | Size: 94 KiB |
|
After Width: | Height: | Size: 152 KiB |
|
After Width: | Height: | Size: 191 KiB |
|
After Width: | Height: | Size: 94 KiB |
|
After Width: | Height: | Size: 152 KiB |
|
After Width: | Height: | Size: 191 KiB |
|
After Width: | Height: | Size: 94 KiB |
|
After Width: | Height: | Size: 152 KiB |