diff --git a/.DS_Store b/.DS_Store index f07c7a1..8dc0810 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/.gitignore b/.gitignore index 30273f5..945e274 100644 --- a/.gitignore +++ b/.gitignore @@ -36,3 +36,5 @@ static/ # Log files *.log logs/ + +.env* \ No newline at end of file diff --git a/app/.DS_Store b/app/.DS_Store index 725195a..52c18d1 100644 Binary files a/app/.DS_Store and b/app/.DS_Store differ diff --git a/app/core/logging.py b/app/core/logging.py deleted file mode 100644 index e69de29..0000000 diff --git a/app/home/api/home_admin.py b/app/home/api/home_admin.py index da81d07..2517e18 100644 --- a/app/home/api/home_admin.py +++ b/app/home/api/home_admin.py @@ -1,6 +1,6 @@ from sqladmin import ModelView -from app.home.models import Image, Project +from app.home.models import Image, Project, UserProject class ProjectAdmin(ModelView, model=Project): @@ -100,3 +100,44 @@ class ImageAdmin(ModelView, model=Image): "img_url": "이미지 URL", "created_at": "생성일시", } + + +class UserProjectAdmin(ModelView, model=UserProject): + name = "사용자-프로젝트" + name_plural = "사용자-프로젝트 목록" + icon = "fa-solid fa-link" + category = "프로젝트 관리" + page_size = 20 + + column_list = [ + "id", + "user_id", + "project_id", + ] + + column_details_list = [ + "id", + "user_id", + "project_id", + "user", + "project", + ] + + column_searchable_list = [ + UserProject.user_id, + UserProject.project_id, + ] + + column_sortable_list = [ + UserProject.id, + UserProject.user_id, + UserProject.project_id, + ] + + column_labels = { + "id": "ID", + "user_id": "사용자 ID", + "project_id": "프로젝트 ID", + "user": "사용자", + "project": "프로젝트", + } diff --git a/app/home/api/routers/v1/home.py b/app/home/api/routers/v1/home.py index e7bfe4a..e100329 100644 --- a/app/home/api/routers/v1/home.py +++ b/app/home/api/routers/v1/home.py @@ -27,7 +27,7 @@ from app.utils.chatgpt_prompt import ChatgptService from app.utils.common import generate_task_id from app.utils.logger import get_logger from app.utils.nvMapScraper import NvMapScraper, GraphQLException -from app.utils.prompts.prompts import marketing_prompt +from app.utils.prompts.prompts import marketing_prompt from config import MEDIA_ROOT # 로거 설정 @@ -119,14 +119,18 @@ async def crawling(request_body: CrawlingRequest): 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)") + logger.error( + f"[crawling] Step 1 FAILED - GraphQL 크롤링 실패: {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)") + logger.error( + f"[crawling] Step 1 FAILED - 크롤링 중 예기치 않은 오류: {e} ({step1_elapsed:.1f}ms)" + ) logger.exception("[crawling] Step 1 상세 오류:") raise HTTPException( status_code=status.HTTP_502_BAD_GATEWAY, @@ -135,7 +139,9 @@ async def crawling(request_body: CrawlingRequest): 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)") + logger.info( + f"[crawling] Step 1 완료 - 이미지 {image_count}개 ({step1_elapsed:.1f}ms)" + ) # ========== Step 2: 정보 가공 ========== step2_start = time.perf_counter() @@ -156,7 +162,9 @@ async def crawling(request_body: CrawlingRequest): ) step2_elapsed = (time.perf_counter() - step2_start) * 1000 - logger.info(f"[crawling] Step 2 완료 - {customer_name}, {region} ({step2_elapsed:.1f}ms)") + logger.info( + f"[crawling] Step 2 완료 - {customer_name}, {region} ({step2_elapsed:.1f}ms)" + ) # ========== Step 3: ChatGPT 마케팅 분석 ========== step3_start = time.perf_counter() @@ -167,14 +175,16 @@ async def crawling(request_body: CrawlingRequest): step3_1_start = time.perf_counter() chatgpt_service = ChatgptService() step3_1_elapsed = (time.perf_counter() - step3_1_start) * 1000 - logger.debug(f"[crawling] Step 3-1: 서비스 초기화 완료 ({step3_1_elapsed:.1f}ms)") + logger.debug( + f"[crawling] Step 3-1: 서비스 초기화 완료 ({step3_1_elapsed:.1f}ms)" + ) # Step 3-2: 프롬프트 생성 # step3_2_start = time.perf_counter() input_marketing_data = { - "customer_name" : customer_name, - "region" : region, - "detail_region_info" : road_address or "" + "customer_name": customer_name, + "region": region, + "detail_region_info": road_address or "", } # prompt = chatgpt_service.build_market_analysis_prompt() # prompt1 = marketing_prompt.build_prompt(input_marketing_data) @@ -182,47 +192,64 @@ async def crawling(request_body: CrawlingRequest): # Step 3-3: GPT API 호출 step3_3_start = time.perf_counter() - structured_report = await chatgpt_service.generate_structured_output(marketing_prompt, input_marketing_data) + structured_report = await chatgpt_service.generate_structured_output( + marketing_prompt, input_marketing_data + ) step3_3_elapsed = (time.perf_counter() - step3_3_start) * 1000 - logger.info(f"[crawling] Step 3-3: GPT API 호출 완료 - ({step3_3_elapsed:.1f}ms)") - logger.debug(f"[crawling] Step 3-3: GPT API 호출 완료 - ({step3_3_elapsed:.1f}ms)") - + logger.info( + f"[crawling] Step 3-3: GPT API 호출 완료 - ({step3_3_elapsed:.1f}ms)" + ) + logger.debug( + f"[crawling] Step 3-3: GPT API 호출 완료 - ({step3_3_elapsed:.1f}ms)" + ) # Step 3-4: 응답 파싱 (크롤링에서 가져온 facility_info 전달) step3_4_start = time.perf_counter() - logger.debug(f"[crawling] Step 3-4: 응답 파싱 시작 - facility_info: {scraper.facility_info}") + logger.debug( + f"[crawling] Step 3-4: 응답 파싱 시작 - facility_info: {scraper.facility_info}" + ) # 요약 Deprecated / 20250115 / Selling points를 첫 prompt에서 추출 중 # parsed = await chatgpt_service.parse_marketing_analysis( # structured_report, facility_info=scraper.facility_info # ) - + # marketing_analysis = MarketingAnalysis(**parsed) marketing_analysis = MarketingAnalysis( report=structured_report["report"], tags=structured_report["tags"], - facilities = list([sp['keywords'] for sp in structured_report["selling_points"]])# [json.dumps(structured_report["selling_points"])] # 나중에 Selling Points로 변수와 데이터구조 변경할 것 + facilities=list( + [sp["keywords"] for sp in structured_report["selling_points"]] + ), # [json.dumps(structured_report["selling_points"])] # 나중에 Selling Points로 변수와 데이터구조 변경할 것 ) - # Selling Points 구조 - # print(sp['category']) - # print(sp['keywords']) - # print(sp['description']) + # Selling Points 구조 + # print(sp['category']) + # print(sp['keywords']) + # print(sp['description']) step3_4_elapsed = (time.perf_counter() - step3_4_start) * 1000 - logger.debug(f"[crawling] Step 3-4: 응답 파싱 완료 ({step3_4_elapsed:.1f}ms)") + logger.debug( + 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)") + logger.info( + 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)") + logger.error( + f"[crawling] Step 3 FAILED - GPT 마케팅 분석 중 오류: {e} ({step3_elapsed:.1f}ms)" + ) logger.exception("[crawling] Step 3 상세 오류:") # 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)") + logger.warning( + f"[crawling] Step 2 - base_info 없음, 마케팅 분석 스킵 ({step2_elapsed:.1f}ms)" + ) # ========== 완료 ========== total_elapsed = (time.perf_counter() - request_start) * 1000 @@ -231,16 +258,16 @@ async def crawling(request_body: CrawlingRequest): logger.info(f"[crawling] - Step 1 (크롤링): {step1_elapsed:.1f}ms") if scraper.base_info: logger.info(f"[crawling] - Step 2 (정보가공): {step2_elapsed:.1f}ms") - if 'step3_elapsed' in locals(): + if "step3_elapsed" in locals(): logger.info(f"[crawling] - Step 3 (GPT 분석): {step3_elapsed:.1f}ms") - if 'step3_3_elapsed' in locals(): + if "step3_3_elapsed" in locals(): logger.info(f"[crawling] - GPT API 호출: {step3_3_elapsed:.1f}ms") return { "image_list": scraper.image_link_list, "image_count": len(scraper.image_link_list) if scraper.image_link_list else 0, "processed_info": processed_info, - "marketing_analysis": marketing_analysis + "marketing_analysis": marketing_analysis, } @@ -598,6 +625,15 @@ curl -X POST "http://localhost:8000/image/upload/blob" \\ 400: {"description": "이미지가 제공되지 않음", "model": ErrorResponse}, }, tags=["Image-Blob"], + openapi_extra={ + "requestBody": { + "content": { + "multipart/form-data": { + "encoding": {"files": {"contentType": "application/octet-stream"}} + } + } + } + }, ) async def upload_images_blob( images_json: Optional[str] = Form( @@ -606,7 +642,8 @@ async def upload_images_blob( examples=[IMAGES_JSON_EXAMPLE], ), files: Optional[list[UploadFile]] = File( - default=None, description="이미지 바이너리 파일 목록" + default=None, + description="이미지 바이너리 파일 목록", ), ) -> ImageUploadResponse: """이미지 업로드 (URL + Azure Blob Storage) @@ -674,9 +711,11 @@ async def upload_images_blob( ) stage1_time = time.perf_counter() - logger.info(f"[upload_images_blob] Stage 1 done - urls: {len(url_images)}, " - f"files: {len(valid_files_data)}, " - f"elapsed: {(stage1_time - request_start)*1000:.1f}ms") + logger.info( + f"[upload_images_blob] Stage 1 done - urls: {len(url_images)}, " + f"files: {len(valid_files_data)}, " + f"elapsed: {(stage1_time - request_start) * 1000:.1f}ms" + ) # ========== Stage 2: Azure Blob 업로드 (세션 없음) ========== # 업로드 결과를 저장할 리스트 (나중에 DB에 저장) @@ -695,8 +734,10 @@ async def upload_images_blob( ) filename = f"{name_without_ext}_{img_order:03d}{ext}" - logger.debug(f"[upload_images_blob] Uploading file {idx+1}/{total_files}: " - f"{filename} ({len(file_content)} bytes)") + logger.debug( + f"[upload_images_blob] Uploading file {idx + 1}/{total_files}: " + f"{filename} ({len(file_content)} bytes)" + ) # Azure Blob Storage에 직접 업로드 upload_success = await uploader.upload_image_bytes(file_content, filename) @@ -705,15 +746,21 @@ async def upload_images_blob( blob_url = uploader.public_url blob_upload_results.append((original_name, blob_url)) img_order += 1 - logger.debug(f"[upload_images_blob] File {idx+1}/{total_files} SUCCESS") + logger.debug( + f"[upload_images_blob] File {idx + 1}/{total_files} SUCCESS" + ) else: skipped_files.append(filename) - logger.warning(f"[upload_images_blob] File {idx+1}/{total_files} FAILED") + logger.warning( + f"[upload_images_blob] File {idx + 1}/{total_files} FAILED" + ) stage2_time = time.perf_counter() - logger.info(f"[upload_images_blob] Stage 2 done - blob uploads: " - f"{len(blob_upload_results)}, skipped: {len(skipped_files)}, " - f"elapsed: {(stage2_time - stage1_time)*1000:.1f}ms") + logger.info( + f"[upload_images_blob] Stage 2 done - blob uploads: " + f"{len(blob_upload_results)}, skipped: {len(skipped_files)}, " + f"elapsed: {(stage2_time - stage1_time) * 1000:.1f}ms" + ) # ========== Stage 3: DB 저장 (새 세션으로 빠르게 처리) ========== logger.info("[upload_images_blob] Stage 3 starting - DB save...") @@ -724,9 +771,7 @@ async def upload_images_blob( async with AsyncSessionLocal() as session: # URL 이미지 저장 for url_item in url_images: - img_name = ( - url_item.name or _extract_image_name(url_item.url, img_order) - ) + img_name = url_item.name or _extract_image_name(url_item.url, img_order) image = Image( task_id=task_id, @@ -772,9 +817,11 @@ async def upload_images_blob( await session.commit() stage3_time = time.perf_counter() - logger.info(f"[upload_images_blob] Stage 3 done - " - f"saved: {len(result_images)}, " - f"elapsed: {(stage3_time - stage2_time)*1000:.1f}ms") + logger.info( + f"[upload_images_blob] Stage 3 done - " + 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}") @@ -784,8 +831,10 @@ async def upload_images_blob( detail="이미지 저장 중 데이터베이스 오류가 발생했습니다.", ) except Exception as e: - logger.error(f"[upload_images_blob] Stage 3 EXCEPTION - " - f"task_id: {task_id}, error: {type(e).__name__}: {e}") + logger.error( + f"[upload_images_blob] Stage 3 EXCEPTION - " + f"task_id: {task_id}, error: {type(e).__name__}: {e}" + ) logger.exception("[upload_images_blob] Stage 3 상세 오류:") raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, @@ -796,8 +845,10 @@ async def upload_images_blob( image_urls = [img.img_url for img in result_images] total_time = time.perf_counter() - request_start - logger.info(f"[upload_images_blob] SUCCESS - task_id: {task_id}, " - f"total: {saved_count}, total_time: {total_time*1000:.1f}ms") + logger.info( + f"[upload_images_blob] SUCCESS - task_id: {task_id}, " + f"total: {saved_count}, total_time: {total_time * 1000:.1f}ms" + ) return ImageUploadResponse( task_id=task_id, diff --git a/app/song/api/routers/v1/song.py b/app/song/api/routers/v1/song.py index 9bfd0ff..9a3aa34 100644 --- a/app/song/api/routers/v1/song.py +++ b/app/song/api/routers/v1/song.py @@ -128,7 +128,9 @@ async def generate_song( project = project_result.scalar_one_or_none() if not project: - logger.warning(f"[generate_song] Project NOT FOUND - task_id: {task_id}") + logger.warning( + f"[generate_song] Project NOT FOUND - task_id: {task_id}" + ) raise HTTPException( status_code=404, detail=f"task_id '{task_id}'에 해당하는 Project를 찾을 수 없습니다.", @@ -143,6 +145,13 @@ async def generate_song( .limit(1) ) lyric = lyric_result.scalar_one_or_none() + logger.debug( + f"[generate_song] Lyric query result - " + f"id: {lyric.id if lyric else None}, " + f"project_id: {lyric.project_id if lyric else None}, " + f"task_id: {lyric.task_id if lyric else None}, " + f"lyric_result: {lyric.lyric_result if lyric else None}" + ) if not lyric: logger.warning(f"[generate_song] Lyric NOT FOUND - task_id: {task_id}") @@ -156,13 +165,25 @@ async def generate_song( logger.info( f"[generate_song] Queries completed - task_id: {task_id}, " f"project_id: {project_id}, lyric_id: {lyric_id}, " - f"elapsed: {(query_time - request_start)*1000:.1f}ms" + f"elapsed: {(query_time - request_start) * 1000:.1f}ms" ) # Song 테이블에 초기 데이터 저장 song_prompt = ( f"[Lyrics]\n{request_body.lyrics}\n\n[Genre]\n{request_body.genre}" ) + logger.debug( + f"[generate_song] Lyrics comparison - task_id: {task_id}\n" + f"{'=' * 60}\n" + f"[lyric.lyric_result]\n" + f"{'-' * 60}\n" + f"{lyric.lyric_result}\n" + f"{'=' * 60}\n" + f"[song_prompt]\n" + f"{'-' * 60}\n" + f"{song_prompt}\n" + f"{'=' * 60}" + ) song = Song( project_id=project_id, @@ -181,7 +202,7 @@ async def generate_song( logger.info( f"[generate_song] Stage 1 DONE - Song saved - " f"task_id: {task_id}, song_id: {song_id}, " - f"elapsed: {(stage1_time - request_start)*1000:.1f}ms" + f"elapsed: {(stage1_time - request_start) * 1000:.1f}ms" ) # 세션이 여기서 자동으로 닫힘 @@ -218,7 +239,7 @@ async def generate_song( logger.info( f"[generate_song] Stage 2 DONE - task_id: {task_id}, " f"suno_task_id: {suno_task_id}, " - f"elapsed: {(stage2_time - stage2_start)*1000:.1f}ms" + f"elapsed: {(stage2_time - stage2_start) * 1000:.1f}ms" ) except Exception as e: @@ -264,12 +285,12 @@ async def generate_song( total_time = stage3_time - request_start logger.info( f"[generate_song] Stage 3 DONE - task_id: {task_id}, " - f"elapsed: {(stage3_time - stage3_start)*1000:.1f}ms" + f"elapsed: {(stage3_time - stage3_start) * 1000:.1f}ms" ) logger.info( f"[generate_song] SUCCESS - task_id: {task_id}, " f"suno_task_id: {suno_task_id}, " - f"total_time: {total_time*1000:.1f}ms" + f"total_time: {total_time * 1000:.1f}ms" ) return GenerateSongResponse( @@ -344,14 +365,22 @@ async def get_song_status( Azure Blob Storage에 업로드한 뒤 Song 테이블의 status를 completed로, song_result_url을 Blob URL로 업데이트합니다. """ - suno_task_id = song_id # 임시방편 / 외부 suno 노출 방지 + suno_task_id = song_id # 임시방편 / 외부 suno 노출 방지 logger.info(f"[get_song_status] START - song_id: {suno_task_id}") try: suno_service = SunoService() result = await suno_service.get_task_status(suno_task_id) - logger.debug(f"[get_song_status] Suno API raw response - song_id: {suno_task_id}, result: {result}") + logger.debug( + f"[get_song_status] Suno API raw response - song_id: {suno_task_id}, result: {result}" + ) parsed_response = suno_service.parse_status_response(result) - logger.info(f"[get_song_status] Suno API response - song_id: {suno_task_id}, status: {parsed_response.status}") + logger.info( + f"[get_song_status] Suno API response - song_id: {suno_task_id}, status: {parsed_response.status}" + ) + + if parsed_response.status == "TEXT_SUCCESS" and result: + parsed_response.status = "processing" + return parsed_response # SUCCESS 상태인 경우 백그라운드에서 MP3 다운로드 및 Blob 업로드 진행 if parsed_response.status == "SUCCESS" and result: @@ -365,7 +394,9 @@ async def get_song_status( first_clip = clips_data[0] audio_url = first_clip.get("audioUrl") clip_duration = first_clip.get("duration") - logger.debug(f"[get_song_status] Using first clip - id: {first_clip.get('id')}, audio_url: {audio_url}, duration: {clip_duration}") + logger.debug( + f"[get_song_status] Using first clip - id: {first_clip.get('id')}, audio_url: {audio_url}, duration: {clip_duration}" + ) if audio_url: # song_id로 Song 조회 @@ -376,7 +407,7 @@ async def get_song_status( .limit(1) ) song = song_result.scalar_one_or_none() - + # processing 상태인 경우에만 백그라운드 태스크 실행 (중복 방지) if song and song.status == "processing": # store_name 조회 @@ -388,9 +419,11 @@ async def get_song_status( # 상태를 uploading으로 변경 (중복 호출 방지) song.status = "uploading" - song.suno_audio_id = first_clip.get('id') + song.suno_audio_id = first_clip.get("id") await session.commit() - logger.info(f"[get_song_status] Song status changed to uploading - song_id: {suno_task_id}") + logger.info( + f"[get_song_status] Song status changed to uploading - song_id: {suno_task_id}" + ) # 백그라운드 태스크로 MP3 다운로드 및 Blob 업로드 실행 background_tasks.add_task( @@ -400,10 +433,19 @@ async def get_song_status( store_name=store_name, duration=clip_duration, ) - logger.info(f"[get_song_status] Background task scheduled - song_id: {suno_task_id}, store_name: {store_name}") + logger.info( + f"[get_song_status] Background task scheduled - song_id: {suno_task_id}, store_name: {store_name}" + ) - suno_audio_id = first_clip.get('id') - word_data = await suno_service.get_lyric_timestamp(suno_task_id, suno_audio_id) + suno_audio_id = first_clip.get("id") + word_data = await suno_service.get_lyric_timestamp( + suno_task_id, suno_audio_id + ) + logger.debug( + f"[get_song_status] word_data from get_lyric_timestamp - " + f"suno_task_id: {suno_task_id}, suno_audio_id: {suno_audio_id}, " + f"word_data: {word_data}" + ) lyric_result = await session.execute( select(Lyric) .where(Lyric.task_id == song.task_id) @@ -411,30 +453,52 @@ async def get_song_status( .limit(1) ) lyric = lyric_result.scalar_one_or_none() - gt_lyric = lyric.lyric_result + gt_lyric = lyric.lyric_result lyric_line_list = gt_lyric.split("\n") - sentences = [lyric_line.strip(',. ') for lyric_line in lyric_line_list if lyric_line and lyric_line != "---"] + sentences = [ + lyric_line.strip(",. ") + for lyric_line in lyric_line_list + if lyric_line and lyric_line != "---" + ] + logger.debug( + f"[get_song_status] sentences from lyric - " + f"sentences: {sentences}" + ) + + timestamped_lyrics = suno_service.align_lyrics( + word_data, sentences + ) + logger.debug( + f"[get_song_status] sentences from lyric - " + f"sentences: {sentences}" + ) - timestamped_lyrics = suno_service.align_lyrics(word_data, sentences) # TODO : DB upload timestamped_lyrics - for order_idx, timestamped_lyric in enumerate(timestamped_lyrics): + for order_idx, timestamped_lyric in enumerate( + timestamped_lyrics + ): song_timestamp = SongTimestamp( - suno_audio_id = suno_audio_id, - order_idx = order_idx, - lyric_line = timestamped_lyric["text"], - start_time = timestamped_lyric["start_sec"], - end_time = timestamped_lyric["end_sec"] + suno_audio_id=suno_audio_id, + order_idx=order_idx, + lyric_line=timestamped_lyric["text"], + start_time=timestamped_lyric["start_sec"], + end_time=timestamped_lyric["end_sec"], ) session.add(song_timestamp) - + await session.commit() elif song and song.status == "uploading": - logger.info(f"[get_song_status] SKIPPED - Song is already uploading, song_id: {suno_task_id}") + logger.info( + f"[get_song_status] SKIPPED - Song is already uploading, song_id: {suno_task_id}" + ) + parsed_response.status = "uploading" elif song and song.status == "completed": - logger.info(f"[get_song_status] SKIPPED - Song already completed, song_id: {suno_task_id}") + logger.info( + f"[get_song_status] SKIPPED - Song already completed, song_id: {suno_task_id}" + ) - logger.info(f"[get_song_status] SUCCESS - song_id: {suno_task_id}") + logger.info(f"[get_song_status] END - song_id: {suno_task_id}") return parsed_response except Exception as e: @@ -520,7 +584,9 @@ async def download_song( error_message="Song not found", ) - logger.info(f"[download_song] Song found - task_id: {task_id}, status: {song.status}") + logger.info( + f"[download_song] Song found - task_id: {task_id}, status: {song.status}" + ) # processing 상태인 경우 if song.status == "processing": @@ -559,7 +625,9 @@ async def download_song( ) project = project_result.scalar_one_or_none() - logger.info(f"[download_song] COMPLETED - task_id: {task_id}, song_result_url: {song.song_result_url}") + logger.info( + f"[download_song] COMPLETED - task_id: {task_id}, song_result_url: {song.song_result_url}" + ) return DownloadSongResponse( success=True, status="completed", @@ -621,7 +689,9 @@ async def get_songs( pagination: PaginationParams = Depends(get_pagination_params), ) -> PaginatedResponse[SongListItem]: """완료된 노래 목록을 페이지네이션하여 반환합니다.""" - logger.info(f"[get_songs] START - page: {pagination.page}, page_size: {pagination.page_size}") + logger.info( + f"[get_songs] START - page: {pagination.page}, page_size: {pagination.page_size}" + ) try: offset = (pagination.page - 1) * pagination.page_size @@ -630,10 +700,7 @@ async def get_songs( # task_id별 최신 created_at 조회 latest_subquery = ( - select( - Song.task_id, - func.max(Song.created_at).label("max_created_at") - ) + select(Song.task_id, func.max(Song.created_at).label("max_created_at")) .where(Song.status == "completed") .group_by(Song.task_id) .subquery() @@ -651,8 +718,8 @@ async def get_songs( latest_subquery, and_( Song.task_id == latest_subquery.c.task_id, - Song.created_at == latest_subquery.c.max_created_at - ) + Song.created_at == latest_subquery.c.max_created_at, + ), ) .where(Song.status == "completed") .order_by(Song.created_at.desc()) diff --git a/app/song/api/song_admin.py b/app/song/api/song_admin.py index 577ebf6..2c0b912 100644 --- a/app/song/api/song_admin.py +++ b/app/song/api/song_admin.py @@ -1,6 +1,6 @@ from sqladmin import ModelView -from app.song.models import Song +from app.song.models import Song, SongTimestamp class SongAdmin(ModelView, model=Song): @@ -67,3 +67,59 @@ class SongAdmin(ModelView, model=Song): "song_result_url": "결과 URL", "created_at": "생성일시", } + + +class SongTimestampAdmin(ModelView, model=SongTimestamp): + name = "노래 타임스탬프" + name_plural = "노래 타임스탬프 목록" + icon = "fa-solid fa-clock" + category = "노래 관리" + page_size = 20 + + column_list = [ + "id", + "suno_audio_id", + "order_idx", + "lyric_line", + "start_time", + "end_time", + "created_at", + ] + + column_details_list = [ + "id", + "suno_audio_id", + "order_idx", + "lyric_line", + "start_time", + "end_time", + "created_at", + ] + + form_excluded_columns = ["created_at"] + + column_searchable_list = [ + SongTimestamp.suno_audio_id, + SongTimestamp.lyric_line, + ] + + column_default_sort = (SongTimestamp.created_at, True) + + column_sortable_list = [ + SongTimestamp.id, + SongTimestamp.suno_audio_id, + SongTimestamp.order_idx, + SongTimestamp.start_time, + SongTimestamp.end_time, + SongTimestamp.created_at, + ] + + column_labels = { + "id": "ID", + "suno_audio_id": "Suno 오디오 ID", + "order_idx": "순서", + "lyric_line": "가사", + "start_time": "시작 시간", + "end_time": "종료 시간", + "created_at": "생성일시", + } diff --git a/app/user/api/user_admin.py b/app/user/api/user_admin.py index e69de29..087ae8e 100644 --- a/app/user/api/user_admin.py +++ b/app/user/api/user_admin.py @@ -0,0 +1,215 @@ +from sqladmin import ModelView + +from app.user.models import RefreshToken, SocialAccount, User + + +class UserAdmin(ModelView, model=User): + name = "사용자" + name_plural = "사용자 목록" + icon = "fa-solid fa-user" + category = "사용자 관리" + page_size = 20 + + column_list = [ + "id", + "kakao_id", + "email", + "nickname", + "role", + "is_active", + "is_deleted", + "created_at", + ] + + column_details_list = [ + "id", + "kakao_id", + "email", + "nickname", + "profile_image_url", + "thumbnail_image_url", + "phone", + "name", + "birth_date", + "gender", + "is_active", + "is_admin", + "role", + "is_deleted", + "deleted_at", + "last_login_at", + "created_at", + "updated_at", + ] + + form_excluded_columns = [ + "created_at", + "updated_at", + "user_projects", + "refresh_tokens", + "social_accounts", + ] + + column_searchable_list = [ + User.kakao_id, + User.email, + User.nickname, + User.phone, + User.name, + ] + + column_default_sort = (User.created_at, True) + + column_sortable_list = [ + User.id, + User.kakao_id, + User.email, + User.nickname, + User.role, + User.is_active, + User.is_deleted, + User.created_at, + ] + + column_labels = { + "id": "ID", + "kakao_id": "카카오 ID", + "email": "이메일", + "nickname": "닉네임", + "profile_image_url": "프로필 이미지", + "thumbnail_image_url": "썸네일 이미지", + "phone": "전화번호", + "name": "실명", + "birth_date": "생년월일", + "gender": "성별", + "is_active": "활성화", + "is_admin": "관리자", + "role": "권한", + "is_deleted": "삭제됨", + "deleted_at": "삭제일시", + "last_login_at": "마지막 로그인", + "created_at": "생성일시", + "updated_at": "수정일시", + } + + +class RefreshTokenAdmin(ModelView, model=RefreshToken): + name = "리프레시 토큰" + name_plural = "리프레시 토큰 목록" + icon = "fa-solid fa-key" + category = "사용자 관리" + page_size = 20 + + column_list = [ + "id", + "user_id", + "is_revoked", + "expires_at", + "created_at", + ] + + column_details_list = [ + "id", + "user_id", + "token_hash", + "expires_at", + "is_revoked", + "created_at", + "revoked_at", + "user_agent", + "ip_address", + ] + + form_excluded_columns = ["created_at", "user"] + + column_searchable_list = [ + RefreshToken.user_id, + RefreshToken.token_hash, + RefreshToken.ip_address, + ] + + column_default_sort = (RefreshToken.created_at, True) + + column_sortable_list = [ + RefreshToken.id, + RefreshToken.user_id, + RefreshToken.is_revoked, + RefreshToken.expires_at, + RefreshToken.created_at, + ] + + column_labels = { + "id": "ID", + "user_id": "사용자 ID", + "token_hash": "토큰 해시", + "expires_at": "만료일시", + "is_revoked": "폐기됨", + "created_at": "생성일시", + "revoked_at": "폐기일시", + "user_agent": "User Agent", + "ip_address": "IP 주소", + } + + +class SocialAccountAdmin(ModelView, model=SocialAccount): + name = "소셜 계정" + name_plural = "소셜 계정 목록" + icon = "fa-solid fa-share-nodes" + category = "사용자 관리" + page_size = 20 + + column_list = [ + "id", + "user_id", + "platform", + "platform_username", + "is_active", + "connected_at", + ] + + column_details_list = [ + "id", + "user_id", + "platform", + "platform_user_id", + "platform_username", + "platform_data", + "scope", + "token_expires_at", + "is_active", + "connected_at", + "updated_at", + ] + + form_excluded_columns = ["connected_at", "updated_at", "user"] + + column_searchable_list = [ + SocialAccount.user_id, + SocialAccount.platform, + SocialAccount.platform_user_id, + SocialAccount.platform_username, + ] + + column_default_sort = (SocialAccount.connected_at, True) + + column_sortable_list = [ + SocialAccount.id, + SocialAccount.user_id, + SocialAccount.platform, + SocialAccount.is_active, + SocialAccount.connected_at, + ] + + column_labels = { + "id": "ID", + "user_id": "사용자 ID", + "platform": "플랫폼", + "platform_user_id": "플랫폼 사용자 ID", + "platform_username": "플랫폼 사용자명", + "platform_data": "플랫폼 데이터", + "scope": "권한 범위", + "token_expires_at": "토큰 만료일시", + "is_active": "활성화", + "connected_at": "연동일시", + "updated_at": "수정일시", + } diff --git a/app/user/services/auth.py b/app/user/services/auth.py index 7f39595..d4cec00 100644 --- a/app/user/services/auth.py +++ b/app/user/services/auth.py @@ -114,7 +114,7 @@ class AuthService: user.last_login_at = datetime.now(timezone.utc) await session.commit() - redirect_url = f"https://{prj_settings.PROJECT_DOMAIN}" + redirect_url = f"{prj_settings.PROJECT_DOMAIN}" logger.info(f"[AUTH] 카카오 로그인 완료 - user_id: {user.id}, redirect_url: {redirect_url}") logger.debug(f"[AUTH] 응답 토큰 정보 - access_token: {access_token[:30]}..., refresh_token: {refresh_token[:30]}...") diff --git a/app/utils/logger.py b/app/utils/logger.py index 18bbbae..24ac7e4 100644 --- a/app/utils/logger.py +++ b/app/utils/logger.py @@ -254,14 +254,12 @@ def setup_uvicorn_logging() -> dict: # dictConfig 버전 (필수, 항상 1) # -------------------------------------------------------- "version": 1, - # -------------------------------------------------------- # 기존 로거 비활성화 여부 # False: 기존 로거 유지 (권장) # True: 기존 로거 모두 비활성화 # -------------------------------------------------------- "disable_existing_loggers": False, - # -------------------------------------------------------- # 포맷터 정의 # 로그 메시지의 출력 형식을 지정합니다. @@ -276,12 +274,11 @@ def setup_uvicorn_logging() -> dict: # HTTP 요청 로그용 포맷터 # 사용 가능한 변수: client_addr, request_line, status_code "access": { - "format": "[{asctime}] {levelname:8} [{name}] {client_addr} - \"{request_line}\" {status_code}", + "format": '[{asctime}] {levelname:8} [{name}] {client_addr} - "{request_line}" {status_code}', "datefmt": LoggerConfig.DATE_FORMAT, "style": "{", }, }, - # -------------------------------------------------------- # 핸들러 정의 # 로그를 어디에 출력할지 지정합니다. @@ -300,7 +297,6 @@ def setup_uvicorn_logging() -> dict: "stream": "ext://sys.stdout", }, }, - # -------------------------------------------------------- # 로거 정의 # Uvicorn 내부에서 사용하는 로거들을 설정합니다. @@ -309,19 +305,19 @@ def setup_uvicorn_logging() -> dict: # Uvicorn 메인 로거 "uvicorn": { "handlers": ["default"], - "level": "INFO", + "level": "DEBUG", "propagate": False, # 상위 로거로 전파 방지 }, # 에러/시작/종료 로그 "uvicorn.error": { "handlers": ["default"], - "level": "INFO", + "level": "DEBUG", "propagate": False, }, # HTTP 요청 로그 (GET /path HTTP/1.1 200 등) "uvicorn.access": { "handlers": ["access"], - "level": "INFO", + "level": "DEBUG", "propagate": False, }, }, diff --git a/app/utils/prompts-backup/lyric_prompt.json b/app/utils/prompts-backup/lyric_prompt.json new file mode 100644 index 0000000..14df2c4 --- /dev/null +++ b/app/utils/prompts-backup/lyric_prompt.json @@ -0,0 +1,34 @@ +{ + "model": "gpt-5-mini", + "prompt_variables": [ + "customer_name", + "region", + "detail_region_info", + "marketing_intelligence_summary", + "language", + "promotional_expression_example", + "timing_rules" + ], + "output_format": { + "format": { + "type": "json_schema", + "name": "lyric", + "schema": { + "type": "object", + "properties": { + "lyric": { + "type": "string" + }, + "suno_prompt":{ + "type" : "string" + } + }, + "required": [ + "lyric", "suno_prompt" + ], + "additionalProperties": false + }, + "strict": true + } + } +} \ No newline at end of file diff --git a/app/utils/prompts-backup/lyric_prompt.txt b/app/utils/prompts-backup/lyric_prompt.txt new file mode 100644 index 0000000..f434c6f --- /dev/null +++ b/app/utils/prompts-backup/lyric_prompt.txt @@ -0,0 +1,76 @@ + +[ROLE] +You are a content marketing expert, brand strategist, and creative songwriter +specializing in Korean pension / accommodation businesses. +You create lyrics strictly based on Brand & Marketing Intelligence analysis +and optimized for viral short-form video content. + +[INPUT] +Business Name: {customer_name} +Region: {region} +Region Details: {detail_region_info} +Brand & Marketing Intelligence Report: {marketing_intelligence_summary} +Output Language: {language} + +[INTERNAL ANALYSIS – DO NOT OUTPUT] +Internally analyze the following to guide all creative decisions: +- Core brand identity and positioning +- Emotional hooks derived from selling points +- Target audience lifestyle, desires, and travel motivation +- Regional atmosphere and symbolic imagery +- How the stay converts into “shareable moments” +- Which selling points must surface implicitly in lyrics + +[LYRICS & MUSIC CREATION TASK] +Based on the Brand & Marketing Intelligence Report for [{customer_name} ({region})], generate: +- Original promotional lyrics +- Music attributes for AI music generation (Suno-compatible prompt) +The output must be designed for VIRAL DIGITAL CONTENT +(short-form video, reels, ads). + +[LYRICS REQUIREMENTS] +Mandatory Inclusions: +- Business name +- Region name +- Promotion subject +- Promotional expressions including: +{promotional_expression_example} + +Content Rules: +- Lyrics must be emotionally driven, not descriptive listings +- Selling points must be IMPLIED, not explained +- Must sound natural when sung +- Must feel like a lifestyle moment, not an advertisement + +Tone & Style: +- Warm, emotional, and aspirational +- Trendy, viral-friendly phrasing +- Calm but memorable hooks +- Suitable for travel / stay-related content + +[SONG & MUSIC ATTRIBUTES – FOR SUNO PROMPT] +After the lyrics, generate a concise music prompt including: +Song mood (emotional keywords) +BPM range +Recommended genres (max 2) +Key musical motifs or instruments +Overall vibe (1 short sentence) + +[CRITICAL LANGUAGE REQUIREMENT – ABSOLUTE RULE] +ALL OUTPUT MUST BE 100% WRITTEN IN {language}. +no mixed languages +All names, places, and expressions must be in {language} +Any violation invalidates the entire output + +[OUTPUT RULES – STRICT] +{timing_rules} + +No explanations +No headings +No bullet points +No analysis +No extra text + +[FAILURE FORMAT] +If generation is impossible: +ERROR: Brief reason in English diff --git a/app/utils/prompts-backup/marketing_prompt.json b/app/utils/prompts-backup/marketing_prompt.json new file mode 100644 index 0000000..c84231a --- /dev/null +++ b/app/utils/prompts-backup/marketing_prompt.json @@ -0,0 +1,58 @@ +{ + "model": "gpt-5.2", + "prompt_variables": [ + "customer_name", + "region", + "detail_region_info" + ], + "output_format": { + "format": { + "type": "json_schema", + "name": "report", + "schema": { + "type": "object", + "properties": { + "report": { + "type": "string" + }, + "selling_points": { + "type": "array", + "items": { + "type": "object", + "properties": { + "category": { + "type": "string" + }, + "keywords": { + "type": "string" + }, + "description": { + "type": "string" + } + }, + "required": [ + "category", + "keywords", + "description" + ], + "additionalProperties": false + } + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "report", + "selling_points", + "tags" + ], + "additionalProperties": false + }, + "strict": true + } + } +} \ No newline at end of file diff --git a/app/utils/prompts-backup/marketing_prompt.txt b/app/utils/prompts-backup/marketing_prompt.txt new file mode 100644 index 0000000..2bf9307 --- /dev/null +++ b/app/utils/prompts-backup/marketing_prompt.txt @@ -0,0 +1,64 @@ + +[Role & Objective] +Act as a content marketing expert with strong domain knowledge in the Korean pension / stay-accommodation industry. +Your goal is to produce a Marketing Intelligence Report that will be shown to accommodation owners BEFORE any content is generated. +The report must clearly explain what makes the property sellable, marketable, and scalable through content. + +[INPUT] +- Business Name: {customer_name} +- Region: {region} +- Region Details: {detail_region_info} + +[Core Analysis Requirements] +Analyze the property based on: +Location, concept, and nearby environment +Target customer behavior and reservation decision factors +Include: +- Target customer segments & personas +- Unique Selling Propositions (USPs) +- Competitive landscape (direct & indirect competitors) +- Market positioning + +[Key Selling Point Structuring – UI Optimized] +From the analysis above, extract the main Key Selling Points using the structure below. +Rules: +Focus only on factors that directly influence booking decisions +Each selling point must be concise and visually scannable +Language must be reusable for ads, short-form videos, and listing headlines +Avoid full sentences in descriptions; use short selling phrases +Do not provide in report + +Output format: +[Category] +(Tag keyword – 5~8 words, noun-based, UI oval-style) +One-line selling phrase (not a full sentence) +Limit: +5 to 8 Key Selling Points only +Do not provide in report + +[Content & Automation Readiness Check] +Ensure that: +Each tag keyword can directly map to a content theme +Each selling phrase can be used as: +- Video hook +- Image headline +- Ad copy snippet + + +[Tag Generation Rules] +- Tags must include **only core keywords that can be directly used for viral video song lyrics** +- Each tag should be selected with **search discovery + emotional resonance + reservation conversion** in mind +- The number of tags must be **exactly 5** +- Tags must be **nouns or short keyword phrases**; full sentences are strictly prohibited +- The following categories must be **balanced and all represented**: + 1) **Location / Local context** (region name, neighborhood, travel context) + 2) **Accommodation positioning** (emotional stay, private stay, boutique stay, etc.) + 3) **Emotion / Experience** (healing, rest, one-day escape, memory, etc.) + 4) **SNS / Viral signals** (Instagram vibes, picture-perfect day, aesthetic travel, etc.) + 5) **Travel & booking intent** (travel, getaway, stay, relaxation, etc.) + +- If a brand name exists, **at least one tag must include the brand name or a brand-specific expression** +- Avoid overly generic keywords (e.g., “hotel”, “travel” alone); **prioritize distinctive, differentiating phrases** +- The final output must strictly follow the JSON format below, with no additional text + + "tags": ["Tag1", "Tag2", "Tag3", "Tag4", "Tag5"] diff --git a/app/utils/prompts-backup/marketing_prompt_20260116.txt b/app/utils/prompts-backup/marketing_prompt_20260116.txt new file mode 100644 index 0000000..2cd0921 --- /dev/null +++ b/app/utils/prompts-backup/marketing_prompt_20260116.txt @@ -0,0 +1,62 @@ + +[Role & Objective] +Act as a content marketing expert with strong domain knowledge in the Korean pension / stay-accommodation industry. +Your goal is to produce a Marketing Intelligence Report that will be shown to accommodation owners BEFORE any content is generated. +The report must clearly explain what makes the property sellable, marketable, and scalable through content. + +[INPUT] +- Business Name: {customer_name} +- Region: {region} +- Region Details: {detail_region_info} + +[Core Analysis Requirements] +Analyze the property based on: +Location, concept, photos, online presence, and nearby environment +Target customer behavior and reservation decision factors +Include: +- Target customer segments & personas +- Unique Selling Propositions (USPs) +- Competitive landscape (direct & indirect competitors) +- Market positioning + +[Key Selling Point Structuring – UI Optimized] +From the analysis above, extract the main Key Selling Points using the structure below. +Rules: +Focus only on factors that directly influence booking decisions +Each selling point must be concise and visually scannable +Language must be reusable for ads, short-form videos, and listing headlines +Avoid full sentences in descriptions; use short selling phrases + +Output format: +[Category] +(Tag keyword – 5~8 words, noun-based, UI oval-style) +One-line selling phrase (not a full sentence) +Limit: +5 to 8 Key Selling Points only + +[Content & Automation Readiness Check] +Ensure that: +Each tag keyword can directly map to a content theme +Each selling phrase can be used as: +- Video hook +- Image headline +- Ad copy snippet + + +[Tag Generation Rules] +- Tags must include **only core keywords that can be directly used for viral video song lyrics** +- Each tag should be selected with **search discovery + emotional resonance + reservation conversion** in mind +- The number of tags must be **exactly 5** +- Tags must be **nouns or short keyword phrases**; full sentences are strictly prohibited +- The following categories must be **balanced and all represented**: + 1) **Location / Local context** (region name, neighborhood, travel context) + 2) **Accommodation positioning** (emotional stay, private stay, boutique stay, etc.) + 3) **Emotion / Experience** (healing, rest, one-day escape, memory, etc.) + 4) **SNS / Viral signals** (Instagram vibes, picture-perfect day, aesthetic travel, etc.) + 5) **Travel & booking intent** (travel, getaway, stay, relaxation, etc.) + +- If a brand name exists, **at least one tag must include the brand name or a brand-specific expression** +- Avoid overly generic keywords (e.g., “hotel”, “travel” alone); **prioritize distinctive, differentiating phrases** +- The final output must strictly follow the JSON format below, with no additional text + + "tags": ["Tag1", "Tag2", "Tag3", "Tag4", "Tag5"] diff --git a/app/utils/prompts-backup/prompts.py b/app/utils/prompts-backup/prompts.py new file mode 100644 index 0000000..ba2131f --- /dev/null +++ b/app/utils/prompts-backup/prompts.py @@ -0,0 +1,76 @@ +import os, json +from abc import ABCMeta +from config import prompt_settings +from app.utils.logger import get_logger + +logger = get_logger("prompt") + +class Prompt(): + prompt_name : str # ex) marketing_prompt + prompt_template_path : str #프롬프트 경로 + prompt_template : str # fstring 포맷 + prompt_input : list + prompt_output : dict + prompt_model : str + + def __init__(self, prompt_name, prompt_template_path): + self.prompt_name = prompt_name + self.prompt_template_path = prompt_template_path + self.prompt_template, prompt_dict = self.read_prompt() + self.prompt_input = prompt_dict['prompt_variables'] + self.prompt_output = prompt_dict['output_format'] + self.prompt_model = prompt_dict.get('model', "gpt-5-mini") + + def _reload_prompt(self): + self.prompt_template, prompt_dict = self.read_prompt() + self.prompt_input = prompt_dict['prompt_variables'] + self.prompt_output = prompt_dict['output_format'] + self.prompt_model = prompt_dict.get('model', "gpt-5-mini") + + def read_prompt(self) -> tuple[str, dict]: + template_text_path = self.prompt_template_path + ".txt" + prompt_dict_path = self.prompt_template_path + ".json" + with open(template_text_path, "r") as fp: + prompt_template = fp.read() + with open(prompt_dict_path, "r") as fp: + prompt_dict = json.load(fp) + + return prompt_template, prompt_dict + + def build_prompt(self, input_data:dict) -> str: + self.check_input(input_data) + build_template = self.prompt_template + logger.debug(f"build_template: {build_template}") + logger.debug(f"input_data: {input_data}") + build_template = build_template.format(**input_data) + return build_template + + def check_input(self, input_data:dict) -> bool: + missing_variables = input_data.keys() - set(self.prompt_input) + if missing_variables: + raise Exception(f"missing_variable for prompt {self.prompt_name} : {missing_variables}") + + flooding_variables = set(self.prompt_input) - input_data.keys() + if flooding_variables: + raise Exception(f"flooding_variables for prompt {self.prompt_name} : {flooding_variables}") + return True + +marketing_prompt = Prompt( + prompt_name=prompt_settings.MARKETING_PROMPT_NAME, + prompt_template_path=os.path.join(prompt_settings.PROMPT_FOLDER_ROOT, prompt_settings.MARKETING_PROMPT_NAME) +) + +summarize_prompt = Prompt( + prompt_name=prompt_settings.SUMMARIZE_PROMPT_NAME, + prompt_template_path=os.path.join(prompt_settings.PROMPT_FOLDER_ROOT, prompt_settings.SUMMARIZE_PROMPT_NAME) +) + +lyric_prompt = Prompt( + prompt_name=prompt_settings.LYLIC_PROMPT_NAME, + prompt_template_path=os.path.join(prompt_settings.PROMPT_FOLDER_ROOT, prompt_settings.LYLIC_PROMPT_NAME) +) + +def reload_all_prompt(): + marketing_prompt._reload_prompt() + summarize_prompt._reload_prompt() + lyric_prompt._reload_prompt() \ No newline at end of file diff --git a/app/utils/prompts-backup/summarize_prompt.json b/app/utils/prompts-backup/summarize_prompt.json new file mode 100644 index 0000000..873b060 --- /dev/null +++ b/app/utils/prompts-backup/summarize_prompt.json @@ -0,0 +1,33 @@ +{ + "prompt_variables": [ + "report", + "selling_points" + ], + "output_format": { + "format": { + "type": "json_schema", + "name": "tags", + "schema": { + "type": "object", + "properties": { + "category": { + "type": "string" + }, + "tag_keywords": { + "type": "string" + }, + "description": { + "type": "string" + } + }, + "required": [ + "category", + "tag_keywords", + "description" + ], + "additionalProperties": false + }, + "strict": true + } + } +} \ No newline at end of file diff --git a/app/utils/prompts-backup/summarize_prompt.txt b/app/utils/prompts-backup/summarize_prompt.txt new file mode 100644 index 0000000..2cf7828 --- /dev/null +++ b/app/utils/prompts-backup/summarize_prompt.txt @@ -0,0 +1,53 @@ + +입력 : +분석 보고서 +{report} + +셀링 포인트 +{selling_points} + +위 분석 결과를 바탕으로, 주요 셀링 포인트를 다음 구조로 재정리하라. + +조건: +각 셀링 포인트는 반드시 ‘카테고리 → 태그 키워드 → 한 줄 설명’ 구조를 가질 것 +태그 키워드는 UI 상에서 타원(oval) 형태의 시각적 태그로 사용될 것을 가정하여 +- 3 ~ 6단어 이내 +- 명사 또는 명사형 키워드로 작성 +- 설명은 문장이 아닌, 짧은 ‘셀링 문구’ 형태로 작성할 것 +- 광고·숏폼·상세페이지 어디에도 바로 재사용 가능해야 함 +- 전체 셀링 포인트 개수는 5~7개로 제한 + +출력 형식: +[카테고리명] +(태그 키워드) +- 한 줄 설명 문구 + +예시: +[공간 정체성] +(100년 적산가옥 · 시간의 결) +- 하루를 ‘숙박’이 아닌 ‘체류’로 바꾸는 공간 + +[입지 & 희소성] +(말랭이마을 · 로컬 히든플레이스) +- 관광지가 아닌, 군산을 아는 사람의 선택 + +[프라이버시] +(독채 숙소 · 프라이빗 스테이) +- 누구의 방해도 없는 완전한 휴식 구조 + +[비주얼 경쟁력] +(감성 인테리어 · 자연광 스폿) +- 찍는 순간 콘텐츠가 되는 공간 설계 + +[타깃 최적화] +(커플 · 소규모 여행) +- 둘에게 가장 이상적인 공간 밀도 + +[체류 경험] +(아무것도 안 해도 되는 하루) +- 일정 없이도 만족되는 하루 루틴 + +[브랜드 포지션] +(호텔도 펜션도 아닌 아지트) +- 다시 돌아오고 싶은 개인적 장소 + \ No newline at end of file diff --git a/app/utils/prompts/marketing_prompt.json b/app/utils/prompts/marketing_prompt.json index c84231a..d2f29b2 100644 --- a/app/utils/prompts/marketing_prompt.json +++ b/app/utils/prompts/marketing_prompt.json @@ -1,5 +1,5 @@ { - "model": "gpt-5.2", + "model": "gpt-5-mini", "prompt_variables": [ "customer_name", "region", @@ -13,7 +13,36 @@ "type": "object", "properties": { "report": { - "type": "string" + "type": "object", + "properties": { + "summary": { + "type": "string" + }, + "details": { + "type": "array", + "items": { + "type": "object", + "properties": { + "detail_title": { + "type": "string" + }, + "detail_description": { + "type": "string" + } + }, + "required": [ + "detail_title", + "detail_description" + ], + "additionalProperties": false + } + } + }, + "required": [ + "summary", + "details" + ], + "additionalProperties": false }, "selling_points": { "type": "array", @@ -43,12 +72,16 @@ "items": { "type": "string" } + }, + "contents_advise": { + "type": "string" } }, "required": [ "report", "selling_points", - "tags" + "tags", + "contents_advise" ], "additionalProperties": false }, diff --git a/app/utils/prompts/marketing_prompt_20260119.json b/app/utils/prompts/marketing_prompt_20260119.json new file mode 100644 index 0000000..c84231a --- /dev/null +++ b/app/utils/prompts/marketing_prompt_20260119.json @@ -0,0 +1,58 @@ +{ + "model": "gpt-5.2", + "prompt_variables": [ + "customer_name", + "region", + "detail_region_info" + ], + "output_format": { + "format": { + "type": "json_schema", + "name": "report", + "schema": { + "type": "object", + "properties": { + "report": { + "type": "string" + }, + "selling_points": { + "type": "array", + "items": { + "type": "object", + "properties": { + "category": { + "type": "string" + }, + "keywords": { + "type": "string" + }, + "description": { + "type": "string" + } + }, + "required": [ + "category", + "keywords", + "description" + ], + "additionalProperties": false + } + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "report", + "selling_points", + "tags" + ], + "additionalProperties": false + }, + "strict": true + } + } +} \ No newline at end of file diff --git a/app/video/api/video_admin.py b/app/video/api/video_admin.py index 3ea59e0..7e7c2d9 100644 --- a/app/video/api/video_admin.py +++ b/app/video/api/video_admin.py @@ -26,6 +26,7 @@ class VideoAdmin(ModelView, model=Video): "lyric_id", "song_id", "task_id", + "creatomate_render_id", "status", "result_movie_url", "created_at", @@ -56,6 +57,7 @@ class VideoAdmin(ModelView, model=Video): "lyric_id": "가사 ID", "song_id": "노래 ID", "task_id": "작업 ID", + "creatomate_render_id": "Creatomate 렌더 ID", "status": "상태", "result_movie_url": "영상 URL", "created_at": "생성일시", diff --git a/insert_project.md b/insert_project.md new file mode 100644 index 0000000..d56d820 --- /dev/null +++ b/insert_project.md @@ -0,0 +1,140 @@ +# Database INSERT 코드 위치 정리 + +이 문서는 O2O Castad Backend 프로젝트에서 실제 동작하는 모든 DB INSERT 코드의 위치를 정리합니다. + +--- + +## 1. User 모듈 (`app/user`) + +### 1.1 User 모델 INSERT +- **파일**: `app/user/services/auth.py` +- **라인**: 278 +- **메서드**: `_get_or_create_user()` +- **API 엔드포인트**: + - `GET /auth/kakao/callback` - 카카오 로그인 콜백 + - `POST /auth/kakao/verify` - 카카오 인가 코드 검증 +- **설명**: 카카오 로그인 시 신규 사용자 생성 +- **저장 필드**: `kakao_id`, `email`, `nickname`, `profile_image_url`, `thumbnail_image_url` + +### 1.2 RefreshToken 모델 INSERT +- **파일**: `app/user/services/auth.py` +- **라인**: 315 +- **메서드**: `_save_refresh_token()` +- **API 엔드포인트**: + - `GET /auth/kakao/callback` - 카카오 로그인 콜백 + - `POST /auth/kakao/verify` - 카카오 인가 코드 검증 +- **설명**: 로그인 시 리프레시 토큰 저장 +- **저장 필드**: `user_id`, `token_hash`, `expires_at`, `user_agent`, `ip_address` + +--- + +## 2. Lyric 모듈 (`app/lyric`) + +### 2.1 Project 모델 INSERT +- **파일**: `app/lyric/api/routers/v1/lyric.py` +- **라인**: 297 +- **API 엔드포인트**: `POST /lyric/generate` - 가사 생성 +- **설명**: 가사 생성 요청 시 프로젝트 레코드 생성 +- **저장 필드**: `store_name`, `region`, `task_id`, `detail_region_info`, `language` + +### 2.2 Lyric 모델 INSERT +- **파일**: `app/lyric/api/routers/v1/lyric.py` +- **라인**: 317 +- **API 엔드포인트**: `POST /lyric/generate` - 가사 생성 +- **설명**: 가사 생성 요청 시 가사 레코드 생성 (초기 status: `processing`) +- **저장 필드**: `project_id`, `task_id`, `status`, `lyric_prompt`, `lyric_result`, `language` + +--- + +## 3. Song 모듈 (`app/song`) + +### 3.1 Song 모델 INSERT +- **파일**: `app/song/api/routers/v1/song.py` +- **라인**: 178 +- **API 엔드포인트**: `POST /song/generate/{task_id}` - 노래 생성 +- **설명**: 노래 생성 요청 시 노래 레코드 생성 (초기 status: `processing`) +- **저장 필드**: `project_id`, `lyric_id`, `task_id`, `suno_task_id`, `status`, `song_prompt`, `language` + +### 3.2 SongTimestamp 모델 INSERT +- **파일**: `app/song/api/routers/v1/song.py` +- **라인**: 459 +- **API 엔드포인트**: `GET /song/status/{song_id}` - 노래 생성 상태 조회 +- **설명**: Suno API에서 노래 생성 완료(SUCCESS) 시 가사 타임스탬프 저장 (반복문으로 다건 INSERT) +- **저장 필드**: `suno_audio_id`, `order_idx`, `lyric_line`, `start_time`, `end_time` + +--- + +## 4. Video 모듈 (`app/video`) + +### 4.1 Video 모델 INSERT +- **파일**: `app/video/api/routers/v1/video.py` +- **라인**: 247 +- **API 엔드포인트**: `GET /video/generate/{task_id}` - 영상 생성 +- **설명**: 영상 생성 요청 시 영상 레코드 생성 (초기 status: `processing`) +- **저장 필드**: `project_id`, `lyric_id`, `song_id`, `task_id`, `creatomate_render_id`, `status` + +--- + +## 5. Home 모듈 (`app/home`) - 이미지 업로드 + +### 5.1 Image 모델 INSERT (URL 이미지 - 서버 저장) +- **파일**: `app/home/api/routers/v1/home.py` +- **라인**: 486 +- **API 엔드포인트**: `POST /image/upload` (tags: Image-Server) +- **설명**: 외부 URL 이미지를 Image 테이블에 저장 +- **저장 필드**: `task_id`, `img_name`, `img_url`, `img_order` + +### 5.2 Image 모델 INSERT (바이너리 파일 - 서버 저장) +- **파일**: `app/home/api/routers/v1/home.py` +- **라인**: 535 +- **API 엔드포인트**: `POST /image/upload` (tags: Image-Server) +- **설명**: 업로드된 바이너리 파일을 media 폴더에 저장 후 Image 테이블에 저장 +- **저장 필드**: `task_id`, `img_name`, `img_url`, `img_order` + +### 5.3 Image 모델 INSERT (URL 이미지 - Blob) +- **파일**: `app/home/api/routers/v1/home.py` +- **라인**: 782 +- **API 엔드포인트**: `POST /image/upload/blob` (tags: Image-Blob) +- **설명**: 외부 URL 이미지를 Image 테이블에 저장 (Azure Blob 엔드포인트) +- **저장 필드**: `task_id`, `img_name`, `img_url`, `img_order` + +### 5.4 Image 모델 INSERT (Blob 업로드 결과) +- **파일**: `app/home/api/routers/v1/home.py` +- **라인**: 804 +- **API 엔드포인트**: `POST /image/upload/blob` (tags: Image-Blob) +- **설명**: 바이너리 파일을 Azure Blob Storage에 업로드 후 Image 테이블에 저장 +- **저장 필드**: `task_id`, `img_name`, `img_url`, `img_order` + +--- + +## 6. Base Services (미사용) + +아래 파일들은 공통 `_add()` 메서드를 정의하고 있으나, 현재 실제 API에서 직접 호출되지 않습니다. + +| 파일 | 라인 | 비고 | +|------|------|------| +| `app/lyric/services/base.py` | 15 | `_update()`에서만 내부 호출 | +| `app/home/services/base.py` | 15 | `_update()`에서만 내부 호출 | +| `app/song/services/base.py` | 15 | `_update()`에서만 내부 호출 | + +--- + +## 요약 테이블 + +| 모듈 | 모델 | 파일:라인 | API 엔드포인트 | +|------|------|-----------|----------------| +| User | User | `auth.py:278` | `GET /auth/kakao/callback`, `POST /auth/kakao/verify` | +| User | RefreshToken | `auth.py:315` | `GET /auth/kakao/callback`, `POST /auth/kakao/verify` | +| Lyric | Project | `lyric.py:297` | `POST /lyric/generate` | +| Lyric | Lyric | `lyric.py:317` | `POST /lyric/generate` | +| Song | Song | `song.py:178` | `POST /song/generate/{task_id}` | +| Song | SongTimestamp | `song.py:459` | `GET /song/status/{song_id}` | +| Video | Video | `video.py:247` | `GET /video/generate/{task_id}` | +| Home | Image | `home.py:486` | `POST /image/upload` | +| Home | Image | `home.py:535` | `POST /image/upload` | +| Home | Image | `home.py:782` | `POST /image/upload/blob` | +| Home | Image | `home.py:804` | `POST /image/upload/blob` | + +--- + +*생성일: 2026-01-23* diff --git a/main.py b/main.py index 810c8a2..78e1c03 100644 --- a/main.py +++ b/main.py @@ -124,10 +124,7 @@ def custom_openapi(): for method, operation in path_item.items(): if method in ["get", "post", "put", "patch", "delete"]: # /auth/me, /auth/logout 등 인증이 필요한 엔드포인트 - if any( - auth_path in path - for auth_path in ["/auth/me", "/auth/logout"] - ): + if any(auth_path in path for auth_path in ["/auth/me", "/auth/logout"]): operation["security"] = [{"BearerAuth": []}] app.openapi_schema = openapi_schema diff --git a/pyproject.toml b/pyproject.toml index 1baeaec..8262370 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,9 +15,10 @@ dependencies = [ "openai>=2.13.0", "pydantic-settings>=2.12.0", "python-jose[cryptography]>=3.5.0", + "python-multipart>=0.0.21", "redis>=7.1.0", "ruff>=0.14.9", - "scalar-fastapi>=1.5.0", + "scalar-fastapi>=1.6.1", "sqladmin[full]>=0.22.0", "sqlalchemy[asyncio]>=2.0.45", "uuid7>=0.1.0", diff --git a/uv.lock b/uv.lock index ed00052..d873113 100644 --- a/uv.lock +++ b/uv.lock @@ -883,6 +883,7 @@ dependencies = [ { name = "openai" }, { name = "pydantic-settings" }, { name = "python-jose", extra = ["cryptography"] }, + { name = "python-multipart" }, { name = "redis" }, { name = "ruff" }, { name = "scalar-fastapi" }, @@ -909,9 +910,10 @@ requires-dist = [ { name = "openai", specifier = ">=2.13.0" }, { name = "pydantic-settings", specifier = ">=2.12.0" }, { name = "python-jose", extras = ["cryptography"], specifier = ">=3.5.0" }, + { name = "python-multipart", specifier = ">=0.0.21" }, { name = "redis", specifier = ">=7.1.0" }, { name = "ruff", specifier = ">=0.14.9" }, - { name = "scalar-fastapi", specifier = ">=1.5.0" }, + { name = "scalar-fastapi", specifier = "==1.6.1" }, { name = "sqladmin", extras = ["full"], specifier = ">=0.22.0" }, { name = "sqlalchemy", extras = ["asyncio"], specifier = ">=2.0.45" }, { name = "uuid7", specifier = ">=0.1.0" }, @@ -1382,11 +1384,11 @@ wheels = [ [[package]] name = "scalar-fastapi" -version = "1.5.0" +version = "1.6.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b2/1a/4897819ef5682f40cce1f9b552a1e002d394bf7e12e5d6fe62f843ffef51/scalar_fastapi-1.5.0.tar.gz", hash = "sha256:5ae887fcc0db63305dc41dc39d61f7be183256085872aa33b294e0d48e29901e", size = 7447, upload-time = "2025-12-04T19:44:12.04Z" } +sdist = { url = "https://files.pythonhosted.org/packages/db/56/bb31bc04d42b989db6933c2c3272d229d68baff0f95214e28e20886da8f6/scalar_fastapi-1.6.1.tar.gz", hash = "sha256:5d3cc96f0f384d388b58aba576a903b907e3c98dacab1339072210e946c4f033", size = 7753, upload-time = "2026-01-20T21:05:21.659Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7a/cc/c0712ac83a31ae96785d9916d7be54595914aa93375ea5df3ba3eadab8af/scalar_fastapi-1.5.0-py3-none-any.whl", hash = "sha256:8e712599ccdfbb614bff5583fdbee1bef03e5fac1e06520287337ff232cb667a", size = 6828, upload-time = "2025-12-04T19:44:11.242Z" }, + { url = "https://files.pythonhosted.org/packages/e9/d9/6794a883410a01389ba6c7fece82f0586a57a7eaad9372b26016ad84e922/scalar_fastapi-1.6.1-py3-none-any.whl", hash = "sha256:880b3fa76b916fd2ee54b0fe340662944a1f95201f606902c2424b673c2cedaf", size = 7115, upload-time = "2026-01-20T21:05:20.457Z" }, ] [[package]]