diff --git a/app/home/api/routers/v1/home.py b/app/home/api/routers/v1/home.py index fad8ca7..6920fc6 100644 --- a/app/home/api/routers/v1/home.py +++ b/app/home/api/routers/v1/home.py @@ -27,6 +27,7 @@ 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, GraphQLException +from app.utils.prompts.prompts import marketing_prompt # 로거 설정 logger = logging.getLogger(__name__) @@ -172,34 +173,50 @@ async def crawling(request_body: CrawlingRequest): 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 "", - ) + chatgpt_service = ChatgptService() 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)") + # step3_2_start = time.perf_counter() + input_marketing_data = { + "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) + # step3_2_elapsed = (time.perf_counter() - step3_2_start) * 1000 + # print(f"[crawling] Step 3-2: 프롬프트 생성 완료 - ({step3_2_elapsed:.1f}ms)") # Step 3-3: GPT API 호출 step3_3_start = time.perf_counter() - raw_response = await chatgpt_service.generate(prompt) + 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 호출 완료 - 응답 {len(raw_response)}자 ({step3_3_elapsed:.1f}ms)") - print(f"[crawling] Step 3-3: GPT API 호출 완료 - 응답 {len(raw_response)}자 ({step3_3_elapsed:.1f}ms)") + logger.info(f"[crawling] Step 3-3: GPT API 호출 완료 - ({step3_3_elapsed:.1f}ms)") + print(f"[crawling] Step 3-3: GPT API 호출 완료 - ({step3_3_elapsed:.1f}ms)") + # Step 3-4: 응답 파싱 (크롤링에서 가져온 facility_info 전달) step3_4_start = time.perf_counter() print(f"[crawling] Step 3-4: 응답 파싱 시작 - facility_info: {scraper.facility_info}") - parsed = await chatgpt_service.parse_marketing_analysis( - raw_response, 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로 변수와 데이터구조 변경할 것 ) - marketing_analysis = MarketingAnalysis(**parsed) + # Selling Points 구조 + # print(sp['category']) + # print(sp['keywords']) + # print(sp['description']) step3_4_elapsed = (time.perf_counter() - step3_4_start) * 1000 print(f"[crawling] Step 3-4: 응답 파싱 완료 ({step3_4_elapsed:.1f}ms)") @@ -236,7 +253,7 @@ async def crawling(request_body: CrawlingRequest): "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 } diff --git a/app/lyric/api/routers/v1/lyric.py b/app/lyric/api/routers/v1/lyric.py index 13f68f1..ea808b8 100644 --- a/app/lyric/api/routers/v1/lyric.py +++ b/app/lyric/api/routers/v1/lyric.py @@ -43,6 +43,8 @@ from app.lyric.worker.lyric_task import generate_lyric_background from app.utils.chatgpt_prompt import ChatgptService from app.utils.pagination import PaginatedResponse, get_paginated +from app.utils.prompts.prompts import lyric_prompt + router = APIRouter(prefix="/lyric", tags=["lyric"]) @@ -224,6 +226,7 @@ async def generate_lyric( request_start = time.perf_counter() task_id = request_body.task_id + print(f"[generate_lyric] ========== START ==========") print( @@ -237,16 +240,45 @@ async def generate_lyric( step1_start = time.perf_counter() print(f"[generate_lyric] Step 1: 서비스 초기화 및 프롬프트 생성...") - service = ChatgptService( - customer_name=request_body.customer_name, - region=request_body.region, - detail_region_info=request_body.detail_region_info or "", - language=request_body.language, - ) - prompt = service.build_lyrics_prompt() + # service = ChatgptService( + # customer_name=request_body.customer_name, + # region=request_body.region, + # detail_region_info=request_body.detail_region_info or "", + # language=request_body.language, + # ) + + # prompt = service.build_lyrics_prompt() + # 원래는 실제 사용할 프롬프트가 들어가야 하나, 로직이 변경되어 이 시점에서 이곳에서 프롬프트를 생성할 이유가 없어서 삭제됨. + # 기존 코드와의 호환을 위해 동일한 로직으로 프롬프트 생성 + + promotional_expressions = { + "Korean" : "인스타 감성, 사진같은 하루, 힐링, 여행, 감성 숙소", + "English" : "Instagram vibes, picture-perfect day, healing, travel, getaway", + "Chinese" : "网红打卡, 治愈系, 旅行, 度假, 拍照圣地", + "Japanese" : "インスタ映え, 写真のような一日, 癒し, 旅行, 絶景", + "Thai" : "ที่พักสวย, ฮีลใจ, เที่ยว, ถ่ายรูป, วิวสวย", + "Vietnamese" : "check-in đẹp, healing, du lịch, nghỉ dưỡng, view đẹp" + }# HARD CODED, 어디에 정리하지? 아직 정리되지 않음 + + timing_rules = { + "60s" : """ + 8–12 lines + Full verse flow, immersive mood + """ + } + lyric_input_data = { + "customer_name" : request_body.customer_name, + "region" : request_body.region, + "detail_region_info" : request_body.detail_region_info or "", + "marketing_intelligence_summary" : None, # task_idx 변경 후 marketing intelligence summary DB에 저장하고 사용할 필요가 있음 + "language" : request_body.language, + "promotional_expression_example" : promotional_expressions[request_body.language], + "timing_rules" : timing_rules["60s"], # 아직은 선택지 하나 + } + estimated_prompt = lyric_prompt.build_prompt(lyric_input_data) step1_elapsed = (time.perf_counter() - step1_start) * 1000 - print(f"[generate_lyric] Step 1 완료 - 프롬프트 {len(prompt)}자 ({step1_elapsed:.1f}ms)") + #print(f"[generate_lyric] Step 1 완료 - 프롬프트 {len(prompt)}자 ({step1_elapsed:.1f}ms)") # ========== Step 2: Project 테이블에 데이터 저장 ========== step2_start = time.perf_counter() @@ -270,11 +302,12 @@ async def generate_lyric( step3_start = time.perf_counter() print(f"[generate_lyric] Step 3: Lyric 저장 (processing)...") + estimated_prompt = lyric_prompt.build_prompt(lyric_input_data) lyric = Lyric( project_id=project.id, task_id=task_id, status="processing", - lyric_prompt=prompt, + lyric_prompt=estimated_prompt, lyric_result=None, language=request_body.language, ) @@ -292,8 +325,8 @@ async def generate_lyric( background_tasks.add_task( generate_lyric_background, task_id=task_id, - prompt=prompt, - language=request_body.language, + prompt=lyric_prompt, + lyric_input_data=lyric_input_data, ) step4_elapsed = (time.perf_counter() - step4_start) * 1000 diff --git a/app/lyric/services/lyrics.py b/app/lyric/services/lyrics.py deleted file mode 100644 index 99c6e78..0000000 --- a/app/lyric/services/lyrics.py +++ /dev/null @@ -1,852 +0,0 @@ -import random -from typing import List - -from fastapi import Request, status -from fastapi.exceptions import HTTPException -from sqlalchemy import Connection, text -from sqlalchemy.exc import SQLAlchemyError - -from app.lyric.schemas.lyrics_schema import ( - AttributeData, - PromptTemplateData, - SongFormData, - SongSampleData, - StoreData, -) -from app.utils.chatgpt_prompt import chatgpt_api - - -async def get_store_info(conn: Connection) -> List[StoreData]: - try: - query = """SELECT * FROM store_default_info;""" - result = await conn.execute(text(query)) - - all_store_info = [ - StoreData( - id=row[0], - store_info=row[1], - store_name=row[2], - store_category=row[3], - store_region=row[4], - store_address=row[5], - store_phone_number=row[6], - created_at=row[7], - ) - for row in result - ] - - result.close() - return all_store_info - except SQLAlchemyError as e: - print(e) - raise HTTPException( - status_code=status.HTTP_503_SERVICE_UNAVAILABLE, - detail="요청하신 서비스가 잠시 내부적으로 문제가 발생하였습니다.", - ) - except Exception as e: - print(e) - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="알수없는 이유로 서비스 오류가 발생하였습니다", - ) - - -async def get_attribute(conn: Connection) -> List[AttributeData]: - try: - query = """SELECT * FROM attribute;""" - result = await conn.execute(text(query)) - - all_attribute = [ - AttributeData( - id=row[0], - attr_category=row[1], - attr_value=row[2], - created_at=row[3], - ) - for row in result - ] - - result.close() - return all_attribute - except SQLAlchemyError as e: - print(e) - raise HTTPException( - status_code=status.HTTP_503_SERVICE_UNAVAILABLE, - detail="요청하신 서비스가 잠시 내부적으로 문제가 발생하였습니다.", - ) - except Exception as e: - print(e) - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="알수없는 이유로 서비스 오류가 발생하였습니다", - ) - - -async def get_attribute(conn: Connection) -> List[AttributeData]: - try: - query = """SELECT * FROM attribute;""" - result = await conn.execute(text(query)) - - all_attribute = [ - AttributeData( - id=row[0], - attr_category=row[1], - attr_value=row[2], - created_at=row[3], - ) - for row in result - ] - - result.close() - return all_attribute - except SQLAlchemyError as e: - print(e) - raise HTTPException( - status_code=status.HTTP_503_SERVICE_UNAVAILABLE, - detail="요청하신 서비스가 잠시 내부적으로 문제가 발생하였습니다.", - ) - except Exception as e: - print(e) - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="알수없는 이유로 서비스 오류가 발생하였습니다", - ) - - -async def get_sample_song(conn: Connection) -> List[SongSampleData]: - try: - query = """SELECT * FROM song_sample;""" - result = await conn.execute(text(query)) - - all_sample_song = [ - SongSampleData( - id=row[0], - ai=row[1], - ai_model=row[2], - genre=row[3], - sample_song=row[4], - ) - for row in result - ] - - result.close() - return all_sample_song - except SQLAlchemyError as e: - print(e) - raise HTTPException( - status_code=status.HTTP_503_SERVICE_UNAVAILABLE, - detail="요청하신 서비스가 잠시 내부적으로 문제가 발생하였습니다.", - ) - except Exception as e: - print(e) - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="알수없는 이유로 서비스 오류가 발생하였습니다", - ) - - -async def get_prompt_template(conn: Connection) -> List[PromptTemplateData]: - try: - query = """SELECT * FROM prompt_template;""" - result = await conn.execute(text(query)) - - all_prompt_template = [ - PromptTemplateData( - id=row[0], - description=row[1], - prompt=row[2], - ) - for row in result - ] - - result.close() - return all_prompt_template - except SQLAlchemyError as e: - print(e) - raise HTTPException( - status_code=status.HTTP_503_SERVICE_UNAVAILABLE, - detail="요청하신 서비스가 잠시 내부적으로 문제가 발생하였습니다.", - ) - except Exception as e: - print(e) - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="알수없는 이유로 서비스 오류가 발생하였습니다", - ) - - -async def get_song_result(conn: Connection) -> List[PromptTemplateData]: - try: - query = """SELECT * FROM prompt_template;""" - result = await conn.execute(text(query)) - - all_prompt_template = [ - PromptTemplateData( - id=row[0], - description=row[1], - prompt=row[2], - ) - for row in result - ] - - result.close() - return all_prompt_template - except SQLAlchemyError as e: - print(e) - raise HTTPException( - status_code=status.HTTP_503_SERVICE_UNAVAILABLE, - detail="요청하신 서비스가 잠시 내부적으로 문제가 발생하였습니다.", - ) - except Exception as e: - print(e) - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="알수없는 이유로 서비스 오류가 발생하였습니다", - ) - - -async def make_song_result(request: Request, conn: Connection): - try: - # 1. Form 데이터 파싱 - form_data = await SongFormData.from_form(request) - - print(f"\n{'=' * 60}") - print(f"Store ID: {form_data.store_id}") - print(f"Lyrics IDs: {form_data.lyrics_ids}") - print(f"Prompt IDs: {form_data.prompts}") - print(f"{'=' * 60}\n") - - # 2. Store 정보 조회 - store_query = """ - SELECT * FROM store_default_info WHERE id=:id; - """ - store_result = await conn.execute(text(store_query), {"id": form_data.store_id}) - - all_store_info = [ - StoreData( - id=row[0], - store_info=row[1], - store_name=row[2], - store_category=row[3], - store_region=row[4], - store_address=row[5], - store_phone_number=row[6], - created_at=row[7], - ) - for row in store_result - ] - - if not all_store_info: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"Store not found: {form_data.store_id}", - ) - - store_info = all_store_info[0] - print(f"Store: {store_info.store_name}") - - # 3. 속성 조회 -- 단계별 선택 프로세서시 구현 필요 없음 - - # 4. Sample Song 조회 및 결합 - combined_sample_song = None - - if form_data.lyrics_ids: - print(f"\n[샘플 가사 조회] - {len(form_data.lyrics_ids)}개") - - lyrics_query = """ - SELECT sample_song FROM song_sample - WHERE id IN :ids - ORDER BY created_at; - """ - lyrics_result = await conn.execute( - text(lyrics_query), {"ids": tuple(form_data.lyrics_ids)} - ) - - sample_songs = [ - row.sample_song for row in lyrics_result.fetchall() if row.sample_song - ] - - if sample_songs: - combined_sample_song = "\n\n".join( - [f"[샘플 {i + 1}]\n{song}" for i, song in enumerate(sample_songs)] - ) - print(f"{len(sample_songs)}개의 샘플 가사 조회 완료") - else: - print("샘플 가사가 비어있습니다") - else: - print("선택된 lyrics가 없습니다") - - # 5. 템플릿 가져오기 - if not form_data.prompts: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="프롬프트 ID가 필요합니다", - ) - - print("템플릿 가져오기") - - prompts_query = """ - SELECT * FROM prompt_template WHERE id=:id; - """ - - # ✅ 수정: store_query → prompts_query - prompts_result = await conn.execute( - text(prompts_query), {"id": form_data.prompts} - ) - - prompts_info = [ - PromptTemplateData( - id=row[0], - description=row[1], - prompt=row[2], - ) - for row in prompts_result - ] - - if not prompts_info: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"Prompt not found: {form_data.prompts}", - ) - - prompt = prompts_info[0] - print(f"Prompt Template: {prompt.prompt}") - - # ✅ 6. 프롬프트 조합 - updated_prompt = prompt.prompt.replace("###", form_data.attributes_str).format( - name=store_info.store_name or "", - address=store_info.store_address or "", - category=store_info.store_category or "", - description=store_info.store_info or "", - ) - - updated_prompt += f""" - - 다음은 참고해야 하는 샘플 가사 정보입니다. - - 샘플 가사를 참고하여 작곡을 해주세요. - - {combined_sample_song} - """ - - print(f"\n[업데이트된 프롬프트]\n{updated_prompt}\n") - - # 7. 모델에게 요청 - generated_lyrics = await chatgpt_api.generate(prompt=updated_prompt) - - # 글자 수 계산 - total_chars_with_space = len(generated_lyrics) - total_chars_without_space = len( - generated_lyrics.replace(" ", "") - .replace("\n", "") - .replace("\r", "") - .replace("\t", "") - ) - - # final_lyrics 생성 - final_lyrics = f"""속성 {form_data.attributes_str} - 전체 글자 수 (공백 포함): {total_chars_with_space}자 - 전체 글자 수 (공백 제외): {total_chars_without_space}자\r\n\r\n{generated_lyrics}""" - - print("=" * 40) - print("[translate:form_data.attributes_str:] ", form_data.attributes_str) - print("[translate:total_chars_with_space:] ", total_chars_with_space) - print("[translate:total_chars_without_space:] ", total_chars_without_space) - print("[translate:final_lyrics:]") - print(final_lyrics) - print("=" * 40) - - # 8. DB 저장 - insert_query = """ - INSERT INTO song_results_all ( - store_info, store_name, store_category, store_address, store_phone_number, - description, prompt, attr_category, attr_value, - ai, ai_model, genre, - sample_song, result_song, created_at - ) VALUES ( - :store_info, :store_name, :store_category, :store_address, :store_phone_number, - :description, :prompt, :attr_category, :attr_value, - :ai, :ai_model, :genre, - :sample_song, :result_song, NOW() - ); - """ - - # ✅ attr_category, attr_value 추가 - insert_params = { - "store_info": store_info.store_info or "", - "store_name": store_info.store_name, - "store_category": store_info.store_category or "", - "store_address": store_info.store_address or "", - "store_phone_number": store_info.store_phone_number or "", - "description": store_info.store_info or "", - "prompt": form_data.prompts, - "attr_category": ", ".join(form_data.attributes.keys()) - if form_data.attributes - else "", - "attr_value": ", ".join(form_data.attributes.values()) - if form_data.attributes - else "", - "ai": "ChatGPT", - "ai_model": form_data.llm_model, - "genre": "후크송", - "sample_song": combined_sample_song or "없음", - "result_song": final_lyrics, - } - - await conn.execute(text(insert_query), insert_params) - await conn.commit() - - print("결과 저장 완료") - - print("\n전체 결과 조회 중...") - - # 9. 생성 결과 가져오기 (created_at 역순) - select_query = """ - SELECT * FROM song_results_all - ORDER BY created_at DESC; - """ - - all_results = await conn.execute(text(select_query)) - - results_list = [ - { - "id": row.id, - "store_info": row.store_info, - "store_name": row.store_name, - "store_category": row.store_category, - "store_address": row.store_address, - "store_phone_number": row.store_phone_number, - "description": row.description, - "prompt": row.prompt, - "attr_category": row.attr_category, - "attr_value": row.attr_value, - "ai": row.ai, - "ai_model": row.ai_model, - "genre": row.genre, - "sample_song": row.sample_song, - "result_song": row.result_song, - "created_at": row.created_at.isoformat() if row.created_at else None, - } - for row in all_results.fetchall() - ] - - print(f"전체 {len(results_list)}개의 결과 조회 완료\n") - - return results_list - - except HTTPException: - raise - except SQLAlchemyError as e: - print(f"Database Error: {e}") - import traceback - - traceback.print_exc() - raise HTTPException( - status_code=status.HTTP_503_SERVICE_UNAVAILABLE, - detail="데이터베이스 연결에 문제가 발생했습니다.", - ) - except Exception as e: - print(f"Unexpected Error: {e}") - import traceback - - traceback.print_exc() - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="서비스 처리 중 오류가 발생했습니다.", - ) - - -async def get_song_result(conn: Connection): # 반환 타입 수정 - try: - select_query = """ - SELECT * FROM song_results_all - ORDER BY created_at DESC; - """ - - all_results = await conn.execute(text(select_query)) - - results_list = [ - { - "id": row.id, - "store_info": row.store_info, - "store_name": row.store_name, - "store_category": row.store_category, - "store_address": row.store_address, - "store_phone_number": row.store_phone_number, - "description": row.description, - "prompt": row.prompt, - "attr_category": row.attr_category, - "attr_value": row.attr_value, - "ai": row.ai, - "ai_model": row.ai_model, - "season": row.season, - "num_of_people": row.num_of_people, - "people_category": row.people_category, - "genre": row.genre, - "sample_song": row.sample_song, - "result_song": row.result_song, - "created_at": row.created_at.isoformat() if row.created_at else None, - } - for row in all_results.fetchall() - ] - - print(f"전체 {len(results_list)}개의 결과 조회 완료\n") - - return results_list - except HTTPException: # HTTPException은 그대로 raise - raise - except SQLAlchemyError as e: - print(f"Database Error: {e}") - import traceback - - traceback.print_exc() - raise HTTPException( - status_code=status.HTTP_503_SERVICE_UNAVAILABLE, - detail="데이터베이스 연결에 문제가 발생했습니다.", - ) - except Exception as e: - print(f"Unexpected Error: {e}") - import traceback - - traceback.print_exc() - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="서비스 처리 중 오류가 발생했습니다.", - ) - - -async def make_automation(request: Request, conn: Connection): - try: - # 1. Form 데이터 파싱 - form_data = await SongFormData.from_form(request) - - print(f"\n{'=' * 60}") - print(f"Store ID: {form_data.store_id}") - print(f"{'=' * 60}\n") - - # 2. Store 정보 조회 - store_query = """ - SELECT * FROM store_default_info WHERE id=:id; - """ - store_result = await conn.execute(text(store_query), {"id": form_data.store_id}) - - all_store_info = [ - StoreData( - id=row[0], - store_info=row[1], - store_name=row[2], - store_category=row[3], - store_region=row[4], - store_address=row[5], - store_phone_number=row[6], - created_at=row[7], - ) - for row in store_result - ] - - if not all_store_info: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"Store not found: {form_data.store_id}", - ) - - store_info = all_store_info[0] - print(f"Store: {store_info.store_name}") - - # 3. 속성 조회 -- 단계별 선택 프로세서시 구현 필요 없음 - attribute_query = """ - SELECT * FROM attribute; - """ - - attribute_results = await conn.execute(text(attribute_query)) - - # 결과 가져오기 - attribute_rows = attribute_results.fetchall() - - formatted_attributes = "" - selected_categories = [] - selected_values = [] - - if attribute_rows: - attribute_list = [ - AttributeData( - id=row[0], - attr_category=row[1], - attr_value=row[2], - created_at=row[3], - ) - for row in attribute_rows - ] - - # ✅ 각 category에서 하나의 value만 랜덤 선택 - formatted_pairs = [] - for attr in attribute_list: - # 쉼표로 분리 및 공백 제거 - values = [v.strip() for v in attr.attr_value.split(",") if v.strip()] - - if values: - # 랜덤하게 하나만 선택 - selected_value = random.choice(values) - formatted_pairs.append(f"{attr.attr_category} : {selected_value}") - - # ✅ 선택된 category와 value 저장 - selected_categories.append(attr.attr_category) - selected_values.append(selected_value) - - # 최종 문자열 생성 - formatted_attributes = "\n".join(formatted_pairs) - - print(f"\n[포맷팅된 문자열 속성 정보]\n{formatted_attributes}\n") - else: - print("속성 데이터가 없습니다") - formatted_attributes = "" - - # 4. 템플릿 가져오기 - print("템플릿 가져오기 (ID=1)") - - prompts_query = """ - SELECT * FROM prompt_template WHERE id=1; - """ - - prompts_result = await conn.execute(text(prompts_query)) - - row = prompts_result.fetchone() - - if not row: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="Prompt ID 1 not found", - ) - - prompt = PromptTemplateData( - id=row[0], - description=row[1], - prompt=row[2], - ) - - print(f"Prompt Template: {prompt.prompt}") - - # 5. 템플릿 조합 - - updated_prompt = prompt.prompt.replace("###", formatted_attributes).format( - name=store_info.store_name or "", - address=store_info.store_address or "", - category=store_info.store_category or "", - description=store_info.store_info or "", - ) - - print("\n" + "=" * 80) - print("업데이트된 프롬프트") - print("=" * 80) - print(updated_prompt) - print("=" * 80 + "\n") - - # 4. Sample Song 조회 및 결합 - combined_sample_song = None - - if form_data.lyrics_ids: - print(f"\n[샘플 가사 조회] - {len(form_data.lyrics_ids)}개") - - lyrics_query = """ - SELECT sample_song FROM song_sample - WHERE id IN :ids - ORDER BY created_at; - """ - lyrics_result = await conn.execute( - text(lyrics_query), {"ids": tuple(form_data.lyrics_ids)} - ) - - sample_songs = [ - row.sample_song for row in lyrics_result.fetchall() if row.sample_song - ] - - if sample_songs: - combined_sample_song = "\n\n".join( - [f"[샘플 {i + 1}]\n{song}" for i, song in enumerate(sample_songs)] - ) - print(f"{len(sample_songs)}개의 샘플 가사 조회 완료") - else: - print("샘플 가사가 비어있습니다") - else: - print("선택된 lyrics가 없습니다") - - # 1. song_sample 테이블의 모든 ID 조회 - print("\n[샘플 가사 랜덤 선택]") - - all_ids_query = """ - SELECT id FROM song_sample; - """ - ids_result = await conn.execute(text(all_ids_query)) - all_ids = [row.id for row in ids_result.fetchall()] - - print(f"전체 샘플 가사 개수: {len(all_ids)}개") - - # 2. 랜덤하게 3개 선택 (또는 전체 개수가 3개 미만이면 전체) - combined_sample_song = None - - if all_ids: - # 3개 또는 전체 개수 중 작은 값 선택 - sample_count = min(3, len(all_ids)) - selected_ids = random.sample(all_ids, sample_count) - - print(f"랜덤 선택된 ID: {selected_ids}") - - # 3. 선택된 ID로 샘플 가사 조회 - lyrics_query = """ - SELECT sample_song FROM song_sample - WHERE id IN :ids - ORDER BY created_at; - """ - lyrics_result = await conn.execute( - text(lyrics_query), {"ids": tuple(selected_ids)} - ) - - sample_songs = [ - row.sample_song for row in lyrics_result.fetchall() if row.sample_song - ] - - # 4. combined_sample_song 생성 - if sample_songs: - combined_sample_song = "\n\n".join( - [f"[샘플 {i + 1}]\n{song}" for i, song in enumerate(sample_songs)] - ) - print(f"{len(sample_songs)}개의 샘플 가사 조회 완료") - else: - print("샘플 가사가 비어있습니다") - else: - print("song_sample 테이블에 데이터가 없습니다") - - # 5. 프롬프트에 샘플 가사 추가 - if combined_sample_song: - updated_prompt += f""" - - 다음은 참고해야 하는 샘플 가사 정보입니다. - - 샘플 가사를 참고하여 작곡을 해주세요. - - {combined_sample_song} - """ - print("샘플 가사 정보가 프롬프트에 추가되었습니다") - else: - print("샘플 가사가 없어 기본 프롬프트만 사용합니다") - - print(f"\n[최종 프롬프트 길이: {len(updated_prompt)} 자]\n") - - # 7. 모델에게 요청 - generated_lyrics = await chatgpt_api.generate(prompt=updated_prompt) - - # 글자 수 계산 - total_chars_with_space = len(generated_lyrics) - total_chars_without_space = len( - generated_lyrics.replace(" ", "") - .replace("\n", "") - .replace("\r", "") - .replace("\t", "") - ) - - # final_lyrics 생성 - final_lyrics = f"""속성 {formatted_attributes} - 전체 글자 수 (공백 포함): {total_chars_with_space}자 - 전체 글자 수 (공백 제외): {total_chars_without_space}자\r\n\r\n{generated_lyrics}""" - - # 8. DB 저장 - insert_query = """ - INSERT INTO song_results_all ( - store_info, store_name, store_category, store_address, store_phone_number, - description, prompt, attr_category, attr_value, - ai, ai_model, genre, - sample_song, result_song, created_at - ) VALUES ( - :store_info, :store_name, :store_category, :store_address, :store_phone_number, - :description, :prompt, :attr_category, :attr_value, - :ai, :ai_model, :genre, - :sample_song, :result_song, NOW() - ); - """ - print("\n[insert_params 선택된 속성 확인]") - print(f"Categories: {selected_categories}") - print(f"Values: {selected_values}") - print() - - # attr_category, attr_value - insert_params = { - "store_info": store_info.store_info or "", - "store_name": store_info.store_name, - "store_category": store_info.store_category or "", - "store_address": store_info.store_address or "", - "store_phone_number": store_info.store_phone_number or "", - "description": store_info.store_info or "", - "prompt": prompt.id, - # 랜덤 선택된 category와 value 사용 - "attr_category": ", ".join(selected_categories) - if selected_categories - else "", - "attr_value": ", ".join(selected_values) if selected_values else "", - "ai": "ChatGPT", - "ai_model": "gpt-5-mini", - "genre": "후크송", - "sample_song": combined_sample_song or "없음", - "result_song": final_lyrics, - } - - await conn.execute(text(insert_query), insert_params) - await conn.commit() - - print("결과 저장 완료") - - print("\n전체 결과 조회 중...") - - # 9. 생성 결과 가져오기 (created_at 역순) - select_query = """ - SELECT * FROM song_results_all - ORDER BY created_at DESC; - """ - - all_results = await conn.execute(text(select_query)) - - results_list = [ - { - "id": row.id, - "store_info": row.store_info, - "store_name": row.store_name, - "store_category": row.store_category, - "store_address": row.store_address, - "store_phone_number": row.store_phone_number, - "description": row.description, - "prompt": row.prompt, - "attr_category": row.attr_category, - "attr_value": row.attr_value, - "ai": row.ai, - "ai_model": row.ai_model, - "genre": row.genre, - "sample_song": row.sample_song, - "result_song": row.result_song, - "created_at": row.created_at.isoformat() if row.created_at else None, - } - for row in all_results.fetchall() - ] - - print(f"전체 {len(results_list)}개의 결과 조회 완료\n") - - return results_list - - except HTTPException: - raise - except SQLAlchemyError as e: - print(f"Database Error: {e}") - import traceback - - traceback.print_exc() - raise HTTPException( - status_code=status.HTTP_503_SERVICE_UNAVAILABLE, - detail="데이터베이스 연결에 문제가 발생했습니다.", - ) - except Exception as e: - print(f"Unexpected Error: {e}") - import traceback - - traceback.print_exc() - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="서비스 처리 중 오류가 발생했습니다.", - ) diff --git a/app/lyric/worker/lyric_task.py b/app/lyric/worker/lyric_task.py index 886b8fe..8b53b0d 100644 --- a/app/lyric/worker/lyric_task.py +++ b/app/lyric/worker/lyric_task.py @@ -13,6 +13,7 @@ from sqlalchemy.exc import SQLAlchemyError from app.database.session import BackgroundSessionLocal from app.lyric.models import Lyric from app.utils.chatgpt_prompt import ChatgptService +from app.utils.prompts.prompts import Prompt # 로거 설정 logger = logging.getLogger(__name__) @@ -68,8 +69,8 @@ async def _update_lyric_status( async def generate_lyric_background( task_id: str, - prompt: str, - language: str, + prompt: Prompt, + lyric_input_data: dict, # 프롬프트 메타데이터에서 정의된 Input ) -> None: """백그라운드에서 ChatGPT를 통해 가사를 생성하고 Lyric 테이블을 업데이트합니다. @@ -84,20 +85,22 @@ async def generate_lyric_background( 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)}자") + print(f"[generate_lyric_background] language: {lyric_input_data['language']}") + #print(f"[generate_lyric_background] prompt length: {len(prompt)}자") try: # ========== Step 1: ChatGPT 서비스 초기화 ========== step1_start = time.perf_counter() print(f"[generate_lyric_background] Step 1: ChatGPT 서비스 초기화...") - service = ChatgptService( - customer_name="", # 프롬프트가 이미 생성되었으므로 빈 값 - region="", - detail_region_info="", - language=language, - ) + # service = ChatgptService( + # customer_name="", # 프롬프트가 이미 생성되었으므로 빈 값 + # region="", + # detail_region_info="", + # language=language, + # ) + + chatgpt = ChatgptService() step1_elapsed = (time.perf_counter() - step1_start) * 1000 print(f"[generate_lyric_background] Step 1 완료 ({step1_elapsed:.1f}ms)") @@ -107,8 +110,9 @@ async def generate_lyric_background( 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) - + #result = await service.generate(prompt=prompt) + result_response = await chatgpt.generate_structured_output(prompt, lyric_input_data) + result = result_response['lyric'] 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)") diff --git a/app/utils/chatgpt_prompt.py b/app/utils/chatgpt_prompt.py index cca878e..c37310c 100644 --- a/app/utils/chatgpt_prompt.py +++ b/app/utils/chatgpt_prompt.py @@ -5,207 +5,11 @@ import re from openai import AsyncOpenAI from config import apikey_settings +from app.utils.prompts.prompts import Prompt # 로거 설정 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 -2.Conduct an in-depth analysis of [업체명:{customer_name}] in [지역명:{region}] by examining their official website or informations, photos on never map and online presence. Create a comprehensive "[지역 상세: {detail_region_info}]_Brand & Marketing Intelligence Report in Korean, that includes: - -**Core Analysis:** -- Target customer segments & personas -- Unique Selling Propositions (USPs) and competitive differentiators -- Comprehensive competitor landscape analysis (direct & indirect competitors) -- Market positioning assessment - -**Content Strategy Framework:** -- Seasonal content calendar with trend integration -- Visual storytelling direction (shot-by-shot creative guidance) -- Brand tone & voice guidelines -- Content themes aligned with target audience behaviors - -**SEO & AEO Optimization:** -- Recommended primary and long-tail keywords -- SEO-optimized taglines and meta descriptions -- Answer Engine Optimization (AEO) content suggestions -- Local search optimization strategies - -**Actionable Recommendations:** -- Content distribution strategy across platforms -- KPI measurement framework -- Budget allocation recommendations by content type - -콘텐츠 기획(Lyrics, Prompt for SUNO) -1. Based on the Brand & Marketing Intelligence Report for [업체명 + 지역명 / {customer_name} ({region})], create original lyrics and define music attributes (song mood, BPM, genres, and key musical motifs, Prompt for Suno.com) specifically tailored for viral content. -2. The lyrics should include, the name of [ Promotion Subject], [location], [main target],[Famous place, accessible in 10min], promotional words including but not limited to [인스타 감성], [사진같은 하루] - -Deliver outputs optimized for three formats:1 minute. Ensure that each version aligns with the brand's core identity and is suitable for use in digital marketing and social media campaigns, in Korean -""".strip() -# fmt: on - -LYRICS_PROMPT_TEMPLATE = """ -[ROLE] -Content marketing expert and creative songwriter specializing in pension/accommodation services - -[INPUT] -- Business Name: {customer_name} -- Region: {region} -- Region Details: {detail_region_info} -- Output Language: {language} - -[INTERNAL ANALYSIS - DO NOT OUTPUT] -Analyze the following internally to inform lyrics creation: -- Target customer segments and personas -- Unique Selling Propositions (USPs) -- Regional characteristics and nearby attractions (within 10 min access) -- Seasonal appeal points -- Emotional triggers for the target audience - -[LYRICS REQUIREMENTS] -1. Must Include Elements: - - Business name (TRANSLATED or TRANSLITERATED to {language}) - - Region name (TRANSLATED or TRANSLITERATED to {language}) - - Main target audience appeal - - Nearby famous places or regional characteristics - -2. Keywords to Incorporate (use language-appropriate trendy expressions): - - Korean: 인스타 감성, 사진같은 하루, 힐링, 여행, 감성 숙소 - - English: Instagram vibes, picture-perfect day, healing, travel, getaway - - Chinese: 网红打卡, 治愈系, 旅行, 度假, 拍照圣地 - - Japanese: インスタ映え, 写真のような一日, 癒し, 旅行, 絶景 - - Thai: ที่พักสวย, ฮีลใจ, เที่ยว, ถ่ายรูป, วิวสวย - - Vietnamese: check-in đẹp, healing, du lịch, nghỉ dưỡng, view đẹp - -3. Structure: - - Length: For 1-minute video (approximately 8-12 lines) - - Flow: Verse structure suitable for music - - Rhythm: Natural speech rhythm in the specified language - -4. Tone: - - Emotional and heartfelt - - Trendy and viral-friendly - - Relatable to target audience - -[CRITICAL LANGUAGE REQUIREMENT - ABSOLUTE RULE] -ALL OUTPUT MUST BE 100% WRITTEN IN {language} - NO EXCEPTIONS -- ALL lyrics content: {language} ONLY -- ALL proper nouns (business names, region names, place names): MUST be translated or transliterated to {language} -- Korean input like "군산" must become "Gunsan" in English, "群山" in Chinese, "グンサン" in Japanese, etc. -- Korean input like "스테이 머뭄" must become "Stay Meoum" in English, "住留" in Chinese, "ステイモーム" in Japanese, etc. -- ZERO Korean characters (한글) allowed when output language is NOT Korean -- ZERO mixing of languages - the entire output must be monolingual in {language} -- This is a NON-NEGOTIABLE requirement -- Any output containing characters from other languages is considered a COMPLETE FAILURE -- Violation of this rule invalidates the entire response - -[OUTPUT RULES - STRICTLY ENFORCED] -- Output lyrics ONLY -- Lyrics MUST be written ENTIRELY in {language} - NO EXCEPTIONS -- ALL names and places MUST be in {language} script/alphabet -- NO Korean (한글), Chinese (漢字), Japanese (仮名), Thai (ไทย), or Vietnamese (Tiếng Việt) characters unless that is the selected output language -- NO titles, descriptions, analysis, or explanations -- NO greetings or closing remarks -- NO additional commentary before or after lyrics -- NO line numbers or labels -- Follow the exact format below - -[OUTPUT FORMAT - SUCCESS] ---- -[Lyrics ENTIRELY in {language} here - no other language characters allowed] ---- - -[OUTPUT FORMAT - FAILURE] -If you cannot generate lyrics due to insufficient information, invalid input, or any other reason: ---- -ERROR: [Brief reason for failure in English] ---- -""".strip() -# fmt: on - -MARKETING_ANALYSIS_PROMPT_TEMPLATE = """ -[ROLE] -Content marketing expert specializing in pension/accommodation services in Korea - -[INPUT] -- Business Name: {customer_name} -- Region: {region} -- Region Details: {detail_region_info} - -[ANALYSIS REQUIREMENTS] -Provide comprehensive marketing analysis including: -1. Target Customer Segments - - Primary and secondary target personas - - Age groups, travel preferences, booking patterns -2. Unique Selling Propositions (USPs) - - Key differentiators based on location and region details - - Competitive advantages -3. Regional Characteristics - - Nearby attractions and famous places (within 10 min access) - - Local food, activities, and experiences - - Transportation accessibility -4. Seasonal Appeal Points - - Best seasons to visit - - Seasonal activities and events - - Peak/off-peak marketing opportunities -5. Marketing Keywords - - Recommended hashtags and search keywords - - Trending terms relevant to the property - -[ADDITIONAL REQUIREMENTS] -1. Recommended Tags - - Generate 5 recommended hashtags/tags based on the business characteristics - - Tags should be trendy, searchable, and relevant to accommodation marketing - - Return as JSON with key "tags" - - **MUST be written in Korean (한국어)** - -[CRITICAL LANGUAGE REQUIREMENT - ABSOLUTE RULE] -ALL OUTPUT MUST BE WRITTEN IN KOREAN (한국어) -- Analysis sections: Korean only -- Tags: Korean only -- This is a NON-NEGOTIABLE requirement -- Any output in English or other languages is considered a FAILURE -- Violation of this rule invalidates the entire response - -[OUTPUT RULES - STRICTLY ENFORCED] -- Output analysis ONLY -- ALL content MUST be written in Korean (한국어) - NO EXCEPTIONS -- NO greetings or closing remarks -- NO additional commentary before or after analysis -- Follow the exact format below - -[OUTPUT FORMAT - SUCCESS] ---- -## 타겟 고객 분석 -[한국어로 작성된 타겟 고객 분석] - -## 핵심 차별점 (USP) -[한국어로 작성된 USP 분석] - -## 지역 특성 -[한국어로 작성된 지역 특성 분석] - -## 시즌별 매력 포인트 -[한국어로 작성된 시즌별 분석] - -## 마케팅 키워드 -[한국어로 작성된 마케팅 키워드] - -## JSON Data -```json -{{ - "tags": ["태그1", "태그2", "태그3", "태그4", "태그5"] -}} -``` ---- - -[OUTPUT FORMAT - FAILURE] -If you cannot generate analysis due to insufficient information, invalid input, or any other reason: ---- -ERROR: [Brief reason for failure in English] ---- -""".strip() # fmt: on @@ -215,190 +19,30 @@ class ChatgptService: GPT 5.0 모델을 사용하여 마케팅 가사 및 분석을 생성합니다. """ - def __init__( - self, - customer_name: str, - region: str, - detail_region_info: str = "", - language: str = "Korean", - ): - # 최신 모델: gpt-5-mini - self.model = "gpt-5-mini" + def __init__(self): self.client = AsyncOpenAI(api_key=apikey_settings.CHATGPT_API_KEY) - self.customer_name = customer_name - self.region = region - self.detail_region_info = detail_region_info - self.language = language - - def build_lyrics_prompt(self) -> str: - """LYRICS_PROMPT_TEMPLATE에 고객 정보를 대입하여 완성된 프롬프트 반환""" - return LYRICS_PROMPT_TEMPLATE.format( - customer_name=self.customer_name, - region=self.region, - detail_region_info=self.detail_region_info, - language=self.language, + + async def _call_structured_output_with_response_gpt_api(self, prompt: str, output_format : dict, model:str) -> dict: + content = [{"type": "input_text", "text": prompt}] + response = await self.client.responses.create( + model=model, + input=[{"role": "user", "content": content}], + text = output_format ) + structured_output = json.loads(response.output_text) + return structured_output or {} - def build_market_analysis_prompt(self) -> str: - """MARKETING_ANALYSIS_PROMPT_TEMPLATE에 고객 정보를 대입하여 완성된 프롬프트 반환""" - return MARKETING_ANALYSIS_PROMPT_TEMPLATE.format( - customer_name=self.customer_name, - region=self.region, - detail_region_info=self.detail_region_info, - ) - - 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( + async def generate_structured_output( self, - prompt: str | None = None, + prompt : Prompt, + input_data : dict, ) -> str: - """GPT에게 프롬프트를 전달하여 결과를 반환합니다. + prompt_text = prompt.build_prompt(input_data) - 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}") + print(f"[ChatgptService] Generated Prompt (length: {len(prompt_text)})") + logger.info(f"[ChatgptService] Starting GPT request with structured output with model: {prompt.prompt_model}") # GPT API 호출 - response = await self._call_gpt_api(prompt) + response = await self._call_structured_output_with_response_gpt_api(prompt_text, prompt.prompt_output, prompt.prompt_model) - 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자로 요약 정리. - - Args: - text: 요약할 마케팅 텍스트 - - Returns: - 요약된 텍스트 - - Raises: - APIError, APIConnectionError, RateLimitError: OpenAI API 오류 - """ - prompt = f"""[ROLE] -마케팅 콘텐츠 요약 전문가 - -[INPUT] -{text} - -[TASK] -위 텍스트를 분석하여 핵심 내용을 항목별로 구분하여 500자 이내로 요약해주세요. - -[OUTPUT REQUIREMENTS] -- 5개 항목으로 구분: 타겟 고객, 핵심 차별점, 지역 특성, 시즌별 포인트, 추천 키워드 -- 각 항목은 줄바꿈으로 구분 -- 총 500자 이내로 요약 -- 핵심 정보만 간결하게 포함 -- 한국어로 작성 -- 특수문자 사용 금지 (괄호, 슬래시, 하이픈, 물결표 등 제외) -- 쉼표와 마침표만 사용하여 자연스러운 문장으로 작성 - -[OUTPUT FORMAT - 반드시 아래 형식 준수] ---- -타겟 고객 -[대상 고객층을 자연스러운 문장으로 설명] - -핵심 차별점 -[숙소의 차별화 포인트를 자연스러운 문장으로 설명] - -지역 특성 -[주변 관광지와 지역 특색을 자연스러운 문장으로 설명] - -시즌별 포인트 -[계절별 매력 포인트를 자연스러운 문장으로 설명] - -추천 키워드 -[마케팅에 활용할 키워드를 쉼표로 구분하여 나열] ---- -""" - - result = await self.generate(prompt=prompt) - - # --- 구분자 제거 - if result.startswith("---"): - result = result[3:].strip() - if result.endswith("---"): - result = result[:-3].strip() - - return result - - async def parse_marketing_analysis( - self, raw_response: str, facility_info: str | None = None - ) -> dict: - """ChatGPT 마케팅 분석 응답을 파싱하고 요약하여 딕셔너리로 반환 - - Args: - raw_response: ChatGPT 마케팅 분석 응답 원문 - facility_info: 크롤링에서 가져온 편의시설 정보 문자열 - - Returns: - dict: {"report": str, "tags": list[str], "facilities": list[str]} - """ - tags: list[str] = [] - facilities: list[str] = [] - report = raw_response - - # JSON 블록 추출 시도 - json_match = re.search(r"```json\s*(\{.*?\})\s*```", raw_response, re.DOTALL) - if json_match: - try: - json_data = json.loads(json_match.group(1)) - tags = json_data.get("tags", []) - print(f"[parse_marketing_analysis] GPT 응답에서 tags 파싱 완료: {tags}") - # JSON 블록을 제외한 리포트 부분 추출 - report = raw_response[: json_match.start()].strip() - # --- 구분자 제거 - if report.startswith("---"): - report = report[3:].strip() - if report.endswith("---"): - report = report[:-3].strip() - except json.JSONDecodeError: - print("[parse_marketing_analysis] JSON 파싱 실패") - pass - - # 크롤링에서 가져온 facility_info로 facilities 설정 - print(f"[parse_marketing_analysis] 크롤링 facility_info 원본: {facility_info}") - if facility_info: - # 쉼표로 구분된 편의시설 문자열을 리스트로 변환 - facilities = [f.strip() for f in facility_info.split(",") if f.strip()] - print(f"[parse_marketing_analysis] facility_info 파싱 결과: {facilities}") - else: - facilities = ["등록된 정보 없음"] - print("[parse_marketing_analysis] facility_info 없음 - '등록된 정보 없음' 설정") - - # 리포트 내용을 500자로 요약 - if report: - report = await self.summarize_marketing(report) - - print(f"[parse_marketing_analysis] 최종 facilities: {facilities}") - return {"report": report, "tags": tags, "facilities": facilities} diff --git a/app/utils/prompts/lyric_prompt.json b/app/utils/prompts/lyric_prompt.json new file mode 100644 index 0000000..1d4ffa8 --- /dev/null +++ b/app/utils/prompts/lyric_prompt.json @@ -0,0 +1 @@ +{"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"}}, "required": ["lyric"], "additionalProperties": false}, "strict": true}}} \ No newline at end of file diff --git a/app/utils/prompts/lyric_prompt.txt b/app/utils/prompts/lyric_prompt.txt new file mode 100644 index 0000000..e379cb2 --- /dev/null +++ b/app/utils/prompts/lyric_prompt.txt @@ -0,0 +1,78 @@ + +[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_expressions[language]} + +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} +8–12 lines +Full verse flow, immersive mood + +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/marketing_prompt.json b/app/utils/prompts/marketing_prompt.json new file mode 100644 index 0000000..e9e42dd --- /dev/null +++ b/app/utils/prompts/marketing_prompt.json @@ -0,0 +1 @@ +{"model": "gpt-5-mini", "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/marketing_prompt.txt b/app/utils/prompts/marketing_prompt.txt new file mode 100644 index 0000000..2cd0921 --- /dev/null +++ b/app/utils/prompts/marketing_prompt.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/prompts.py b/app/utils/prompts/prompts.py new file mode 100644 index 0000000..b224cbd --- /dev/null +++ b/app/utils/prompts/prompts.py @@ -0,0 +1,69 @@ +import os, json +from abc import ABCMeta +from config import prompt_settings + +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 + print("build_template", build_template) + print("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}") + 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/summarize_prompt.json b/app/utils/prompts/summarize_prompt.json new file mode 100644 index 0000000..873b060 --- /dev/null +++ b/app/utils/prompts/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/summarize_prompt.txt b/app/utils/prompts/summarize_prompt.txt new file mode 100644 index 0000000..2cf7828 --- /dev/null +++ b/app/utils/prompts/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/config.py b/config.py index 9ba8503..50cf8b5 100644 --- a/config.py +++ b/config.py @@ -167,6 +167,13 @@ class CreatomateSettings(BaseSettings): model_config = _base_config +class PromptSettings(BaseSettings): + PROMPT_FOLDER_ROOT : str = Field(default="./app/utils/prompts") + MARKETING_PROMPT_NAME : str = Field(default="marketing_prompt") + SUMMARIZE_PROMPT_NAME : str = Field(default="summarize_prompt") + LYLIC_PROMPT_NAME : str = Field(default="lyric_prompt") + + model_config = _base_config prj_settings = ProjectSettings() apikey_settings = APIKeySettings() @@ -177,3 +184,4 @@ cors_settings = CORSSettings() crawler_settings = CrawlerSettings() azure_blob_settings = AzureBlobSettings() creatomate_settings = CreatomateSettings() +prompt_settings = PromptSettings() \ No newline at end of file diff --git a/main_tester.ipynb b/main_tester.ipynb new file mode 100644 index 0000000..3fef559 --- /dev/null +++ b/main_tester.ipynb @@ -0,0 +1,723 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": 2, + "id": "e7af5103-62db-4a32-b431-6395c85d7ac9", + "metadata": {}, + "outputs": [], + "source": [ + "from app.home.api.routers.v1.home import crawling\n", + "from app.utils.prompts import prompts" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "6cf7ae9b-3ffe-4046-9cab-f33bc071b288", + "metadata": {}, + "outputs": [], + "source": [ + "from config import crawler_settings" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "4c4ec4c5-9efb-470f-99cf-a18a5b80352f", + "metadata": {}, + "outputs": [], + "source": [ + "from app.home.schemas.home_schema import (\n", + " CrawlingRequest,\n", + " CrawlingResponse,\n", + " ErrorResponse,\n", + " ImageUploadResponse,\n", + " ImageUploadResultItem,\n", + " ImageUrlItem,\n", + " MarketingAnalysis,\n", + " ProcessedInfo,\n", + ")\n", + "import json" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "be5d0e16-8cc6-44d4-ae93-8252caa09940", + "metadata": {}, + "outputs": [], + "source": [ + "val1 = CrawlingRequest(**{\"url\" : 'https://map.naver.com/p/entry/place/1903455560?placePath=/home?from=map&fromPanelNum=1&additionalHeight=76×tamp=202601131552&locale=ko&svcName=map_pcv5&businessCategory=pension&c=15.00,0,0,0,dh'})" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "c13742d7-70f4-4a6d-90c2-8b84f245a08c", + "metadata": {}, + "outputs": [], + "source": [ + "from app.utils.prompts.prompts import reload_all_prompt\n", + "reload_all_prompt()" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "d4db2ec1-b2af-4993-8832-47f380c17015", + "metadata": { + "scrolled": true + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "[crawling] ========== START ==========\n", + "[crawling] URL: https://map.naver.com/p/entry/place/1903455560?placePath=/home?from=map&fromPane...\n", + "[crawling] Step 1: 네이버 지도 크롤링 시작...\n", + "[NvMapScraper] Requesting place_id: 1903455560\n", + "[NvMapScraper] SUCCESS - place_id: 1903455560\n", + "[crawling] Step 1 완료 - 이미지 44개 (659.9ms)\n", + "[crawling] Step 2: 정보 가공 시작...\n", + "[crawling] Step 2 완료 - 오블로모프, 군산시 (0.7ms)\n", + "[crawling] Step 3: ChatGPT 마케팅 분석 시작...\n", + "[crawling] Step 3-1: 서비스 초기화 완료 (59.1ms)\n", + "build_template \n", + "[Role & Objective]\n", + "Act as a content marketing expert with strong domain knowledge in the Korean pension / stay-accommodation industry.\n", + "Your goal is to produce a Marketing Intelligence Report that will be shown to accommodation owners BEFORE any content is generated.\n", + "The report must clearly explain what makes the property sellable, marketable, and scalable through content.\n", + "\n", + "[INPUT]\n", + "- Business Name: {customer_name}\n", + "- Region: {region}\n", + "- Region Details: {detail_region_info}\n", + "\n", + "[Core Analysis Requirements]\n", + "Analyze the property based on:\n", + "Location, concept, photos, online presence, and nearby environment\n", + "Target customer behavior and reservation decision factors\n", + "Include:\n", + "- Target customer segments & personas\n", + "- Unique Selling Propositions (USPs)\n", + "- Competitive landscape (direct & indirect competitors)\n", + "- Market positioning\n", + "\n", + "[Key Selling Point Structuring – UI Optimized]\n", + "From the analysis above, extract the main Key Selling Points using the structure below.\n", + "Rules:\n", + "Focus only on factors that directly influence booking decisions\n", + "Each selling point must be concise and visually scannable\n", + "Language must be reusable for ads, short-form videos, and listing headlines\n", + "Avoid full sentences in descriptions; use short selling phrases\n", + "\n", + "Output format:\n", + "[Category]\n", + "(Tag keyword – 5~8 words, noun-based, UI oval-style)\n", + "One-line selling phrase (not a full sentence)\n", + "Limit:\n", + "5 to 8 Key Selling Points only\n", + "\n", + "[Content & Automation Readiness Check]\n", + "Ensure that:\n", + "Each tag keyword can directly map to a content theme\n", + "Each selling phrase can be used as:\n", + "- Video hook\n", + "- Image headline\n", + "- Ad copy snippet\n", + "\n", + "\n", + "[Tag Generation Rules]\n", + "- Tags must include **only core keywords that can be directly used for viral video song lyrics**\n", + "- Each tag should be selected with **search discovery + emotional resonance + reservation conversion** in mind\n", + "- The number of tags must be **exactly 5**\n", + "- Tags must be **nouns or short keyword phrases**; full sentences are strictly prohibited\n", + "- The following categories must be **balanced and all represented**:\n", + " 1) **Location / Local context** (region name, neighborhood, travel context)\n", + " 2) **Accommodation positioning** (emotional stay, private stay, boutique stay, etc.)\n", + " 3) **Emotion / Experience** (healing, rest, one-day escape, memory, etc.)\n", + " 4) **SNS / Viral signals** (Instagram vibes, picture-perfect day, aesthetic travel, etc.)\n", + " 5) **Travel & booking intent** (travel, getaway, stay, relaxation, etc.)\n", + "\n", + "- If a brand name exists, **at least one tag must include the brand name or a brand-specific expression**\n", + "- Avoid overly generic keywords (e.g., “hotel”, “travel” alone); **prioritize distinctive, differentiating phrases**\n", + "- The final output must strictly follow the JSON format below, with no additional text\n", + "\n", + " \"tags\": [\"Tag1\", \"Tag2\", \"Tag3\", \"Tag4\", \"Tag5\"]\n", + "\n", + "input_data {'customer_name': '오블로모프', 'region': '군산시', 'detail_region_info': '전북 군산시 절골길 16'}\n", + "[ChatgptService] Generated Prompt (length: 2766)\n", + "[crawling] Step 3-3: GPT API 호출 완료 - (59060.2ms)\n", + "[crawling] Step 3-4: 응답 파싱 시작 - facility_info: 무선 인터넷, 예약, 주차\n", + "[crawling] Step 3-4: 응답 파싱 완료 (0.1ms)\n", + "[crawling] Step 3 완료 - 마케팅 분석 성공 (59119.8ms)\n", + "[crawling] ========== COMPLETE ==========\n", + "[crawling] 총 소요시간: 59782.3ms\n", + "[crawling] - Step 1 (크롤링): 659.9ms\n", + "[crawling] - Step 2 (정보가공): 0.7ms\n", + "[crawling] - Step 3 (GPT 분석): 59119.8ms\n", + "[crawling] - GPT API 호출: 59060.2ms\n" + ] + } + ], + "source": [ + "var2 = await crawling(val1)" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "79f093f0-d7d2-4ed1-ba43-da06e4ee2073", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "{'image_list': ['https://ldb-phinf.pstatic.net/20230515_163/1684090233619kRU3v_JPEG/20230513_154207.jpg',\n", + " 'https://ldb-phinf.pstatic.net/20250811_213/17548982879808X4MH_PNG/1.png',\n", + " 'https://ldb-phinf.pstatic.net/20240409_34/1712622373542UY8aC_JPEG/20231007_051403.jpg',\n", + " 'https://ldb-phinf.pstatic.net/20230515_37/1684090234513tT89X_JPEG/20230513_152018.jpg',\n", + " 'https://ldb-phinf.pstatic.net/20241231_272/1735620966755B9XgT_PNG/DSC09054.png',\n", + " 'https://ldb-phinf.pstatic.net/20240409_100/1712622410472zgP15_JPEG/20230523_153219.jpg',\n", + " 'https://ldb-phinf.pstatic.net/20240409_151/1712623034401FzQbd_JPEG/Screenshot_20240409_093158_Airbnb.jpg',\n", + " 'https://ldb-phinf.pstatic.net/20240409_169/1712622316504ReKji_JPEG/20230728_125946.jpg',\n", + " 'https://ldb-phinf.pstatic.net/20230521_279/1684648422643NI2oj_JPEG/20230521_144343.jpg',\n", + " 'https://ldb-phinf.pstatic.net/20240409_52/1712622993632WR1sT_JPEG/Screenshot_20240409_093237_Airbnb.jpg',\n", + " 'https://ldb-phinf.pstatic.net/20250811_151/1754898220223TNtvB_PNG/2.png',\n", + " 'https://ldb-phinf.pstatic.net/20240409_70/1712622381167p9QOI_JPEG/20230608_175722.jpg',\n", + " 'https://ldb-phinf.pstatic.net/20230515_144/1684090233161cR5mr_JPEG/20230513_180151.jpg',\n", + " 'https://ldb-phinf.pstatic.net/20240409_158/1712621983956CCqdo_JPEG/20240407_121826.jpg',\n", + " 'https://ldb-phinf.pstatic.net/20250811_187/1754893113769iGO5X_JPEG/%B0%C5%BD%C7_01.jpg',\n", + " 'https://ldb-phinf.pstatic.net/20240409_31/17126219901822nnR4_JPEG/20240407_121615.jpg',\n", + " 'https://ldb-phinf.pstatic.net/20240409_94/1712621993863AWMKi_JPEG/20240407_121520.jpg',\n", + " 'https://ldb-phinf.pstatic.net/20230515_165/1684090236297fVhJM_JPEG/20230513_165348.jpg',\n", + " 'https://ldb-phinf.pstatic.net/20230515_102/1684090230350e1v0E_JPEG/20230513_162718.jpg',\n", + " 'https://ldb-phinf.pstatic.net/20230515_26/1684090232743arN2y_JPEG/20230513_174246.jpg',\n", + " 'https://ldb-phinf.pstatic.net/20250811_273/1754893072358V3WcL_JPEG/%B5%F0%C5%D7%C0%CF%C4%C6_02.jpg',\n", + " 'https://ldb-phinf.pstatic.net/20240409_160/1712621974438LLNbD_JPEG/20240407_121848.jpg',\n", + " 'https://ldb-phinf.pstatic.net/20240409_218/1712623006036U39zE_JPEG/Screenshot_20240409_093114_Airbnb.jpg',\n", + " 'https://ldb-phinf.pstatic.net/20230515_210/16840902342654EkeL_JPEG/20230513_152107.jpg',\n", + " 'https://ldb-phinf.pstatic.net/20240409_216/1712623058832HBulg_JPEG/Screenshot_20240409_093309_Airbnb.jpg',\n", + " 'https://ldb-phinf.pstatic.net/20230515_184/1684090223226nO2Az_JPEG/20230514_143325.jpg',\n", + " 'https://ldb-phinf.pstatic.net/20230515_209/1684090697642BHNVR_JPEG/20230514_143528.jpg',\n", + " 'https://ldb-phinf.pstatic.net/20240409_16/1712623029052VNeaz_JPEG/Screenshot_20240409_093141_Airbnb.jpg',\n", + " 'https://ldb-phinf.pstatic.net/20230515_141/1684090233092KwtWy_JPEG/20230513_180105.jpg',\n", + " 'https://ldb-phinf.pstatic.net/20240409_177/1712623066424dcwJ2_JPEG/Screenshot_20240409_093511_Airbnb.jpg',\n", + " 'https://ldb-phinf.pstatic.net/20230515_181/16840902259407iA5Q_JPEG/20230514_144814.jpg',\n", + " 'https://ldb-phinf.pstatic.net/20230515_153/1684090224581Ih4ft_JPEG/20230514_143552.jpg',\n", + " 'https://ldb-phinf.pstatic.net/20230515_205/1684090231467WmulO_JPEG/20230513_180254.jpg',\n", + " 'https://ldb-phinf.pstatic.net/20230515_120/1684090231233PkqCf_JPEG/20230513_152550.jpg',\n", + " 'https://ldb-phinf.pstatic.net/20240409_188/1712623039909sflvy_JPEG/Screenshot_20240409_093209_Airbnb.jpg',\n", + " 'https://ldb-phinf.pstatic.net/20240409_165/1712623049073j0TzM_JPEG/Screenshot_20240409_093254_Airbnb.jpg',\n", + " 'https://ldb-phinf.pstatic.net/20240409_3/17126230950579050V_JPEG/Screenshot_20240409_093412_Airbnb.jpg',\n", + " 'https://ldb-phinf.pstatic.net/20240409_270/1712623091524YX4E6_JPEG/Screenshot_20240409_093355_Airbnb.jpg',\n", + " 'https://ldb-phinf.pstatic.net/20240409_22/1712623083348btwTB_JPEG/Screenshot_20240409_093331_Airbnb.jpg',\n", + " 'https://ldb-phinf.pstatic.net/20240409_242/1712623087423Q7tHk_JPEG/Screenshot_20240409_093339_Airbnb.jpg',\n", + " 'https://ldb-phinf.pstatic.net/20240409_173/1712623098958aFhiB_JPEG/Screenshot_20240409_093422_Airbnb.jpg',\n", + " 'https://ldb-phinf.pstatic.net/20240409_113/1712623103270DOGKI_JPEG/Screenshot_20240409_093435_Airbnb.jpg',\n", + " 'https://ldb-phinf.pstatic.net/20240409_295/17126230704056BTRg_JPEG/Screenshot_20240409_093448_Airbnb.jpg',\n", + " 'https://ldb-phinf.pstatic.net/20240409_178/1712623075172JEt43_JPEG/Screenshot_20240409_093457_Airbnb.jpg'],\n", + " 'image_count': 44,\n", + " 'processed_info': ProcessedInfo(customer_name='오블로모프', region='군산시', detail_region_info='전북 군산시 절골길 16'),\n", + " 'marketing_analysis': MarketingAnalysis(report='요약\\n오블로모프(군산 절골길)는 ‘느림의 미학’을 콘셉트로 한 소규모 부티크 스테이로 포지셔닝할 때 강점이 큽니다. 군산의 근대문화·항구·로컬 카페·해산물 레퍼런스가 결합되면 ‘주말 힐링 + 인생샷’ 수요를 끌어올 수 있습니다. 사진·영상 중심의 콘텐츠, 지역 연계 체험, 예약 편의성(주차·즉시예약·정책 명확화)을 우선 강화하면 전환율 개선과 확장성(패키지, 시즌 프로모션)이 용이합니다.\\n위치 분석\\n- 주소: 전북 군산시 절골길 16 — 주거 밀집·골목형 동선으로 ‘조용한 휴식’ 기대 요소\\n- 인근: 근대문화·항구권 관광지·로컬 카페·해산물 식당 밀집(도보·단거리 이동권 장점)\\n- 교통: 자차 접근성·주차 여부가 예약 결정 핵심(대중교통 이용객 대비 자가용 고객 타깃화 필요)\\n콘셉트·포토·온라인 대비\\n- 콘셉트 잠재력: ‘오블로모프=느림·휴식’ 내러티브 활용 가능(브랜드 스토리텔링 유리)\\n- 포토 포인트 제안: 테라스 일출·실내 빈티지 소품·침구 근접 샷·로컬 푸드 플래팅\\n- 온라인: 네이버 예약, 인스타그램, 블로그(지역 키워드) 우선 등재 필요. 리뷰·FAQ·즉시예약 정보 노출 필수\\n주변 환경 영향요인\\n- 식음·체험: 해산물 전문점·카페투어·공방/산책 루트 연계로 1박 체류 가치 강화\\n- 시즌성: 주중 장기 체류보다는 주말·연휴 수요 집중, 계절별 촬영 포인트로 프로모션\\n타깃 고객 행동·예약 결정 요인\\n- 시각 요소 우선: 사진 퀄리티가 예약 전환을 좌우\\n- 프라이버시·편리성: 전용 테라스·주차·와이파이·편의시설(개인화된 체크인)이 중요\\n- 정책: 유연한 취소·즉시예약·가격 패키징(주말/주중/연박)로 예약장벽 완화\\n타깃 세그먼트(페르소나)\\n1) SNS 커플(25–35) — 인생샷·감성카페·주말 데이트 용도\\n2) 휴식형 성인(30–50) — ‘힐링’·프라이버시·느긋한 체류 선호\\n3) 콘텐츠 크리에이터·프리랜서(20–40) — 사진·영상 소재·원데이 촬영 스팟 수요\\n4) 가족·소규모 그룹(30–45) — 주차·편의시설·근거리 식사 옵션 필요\\nUSP(핵심 가치 제안)\\n- 절골길의 조용한 골목 위치로 프라이빗한 휴식 보장\\n- ‘오블로모프’ 감성의 느림·회복 스토리로 차별화\\n- 사진·영상 친화적 인테리어와 야외 테라스(콘텐츠 제작 가치 높음)\\n- 지역 미식·카페 루트와 연계한 플레이스 기반 체류 설계 가능\\n경쟁구도\\n- 직접 경쟁: 군산 내 소규모 펜션·게스트하우스·부티크 스테이\\n- 간접 경쟁: 지역 호텔·에어비앤비·당일치기 여행 코스\\n- 차별화 포인트: 브랜드 스토리(느림)·콘텐츠 친화성·로컬 연계 프로그램\\n시장 포지셔닝 제안\\n- 포지셔닝: ‘군산 절골의 느림 감성 부티크 스테이’ — mid-premium 티어\\n- 가격·프로모션: 주말 프리미엄, 주중 패키지·장기 할인 고려\\n콘텐츠·자동화 준비 체크리스트\\n- 필요 자산: ①외관 황금시간(골목샷) ②테라스/일몰 ③침실·욕실 클로즈업 ④로컬 푸드 컷 ⑤리뷰·게스트 스토리\\n- 콘텐츠 기획: 로컬 루트(카페·식당)·‘하루 힐링’ 숏폼(15–30s)·인테리어 B-roll(10s 반복 가능한 클립)\\n- 예약 전환 포인트 템플릿: 사진 헤로·편의 아이콘(주차·와이파이)·간단 정책·CTA\\n- 태그 매핑: 지역·브랜드·힐링·SNS·여행 의도(ads/shorts/리스트 헤드라인 직접 활용)\\n권장 다음 단계\\n1) 사진 촬영 10컷(상기 항목) 2) 인스타 12포스트 + 숏폼 6개 제작 3) 예약 페이지(네이버·에어비앤비) 표준화 및 FAQ 업데이트\\n', tags=['군산절골', '오블로모프스테이', '힐링스테이', '인생샷스폿', '주말여행'], facilities=['군산 절골길 근대문화 항구 카페거리 해산물', '오블로모프 느림의미학 프라이빗 부티크 스테이', '힐링 휴식 일상탈출 온전한쉼 감성여행', '감성포토 인생샷 테라스 일몰 빈티지인테리어', '주차편의 빠른예약 유연취소 와이파이 원데이스테이', '로컬식당 해산물 카페투어 산책코스 공방체험'])}" + ] + }, + "execution_count": 9, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "var2" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "f3bf1d76-bd2a-43d5-8d39-f0ab2459701a", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Location\n", + "군산 절골 골목 스팟\n", + "골목 깊숙한 전원 감성, 조용한 휴식\n", + "Concept\n", + "프라이빗 전원스테이\n", + "소규모 전용 공간, 맞춤형 프라이버시\n", + "Visuals\n", + "인생샷 포토존\n", + "포토제닉 실내·외 컷, 밤 조명 무드\n", + "Experience\n", + "힐링 리셋 스테이\n", + "일상 탈출·짧은 리셋, 주말 최적\n", + "Digital\n", + "SNS 바이럴 감성\n", + "쇼트폼 영상·릴스용 비주얼 중심\n", + "Booking\n", + "주말 스테이케이션 패키지\n", + "주말 1박 패키지, 조식·체험 옵션\n" + ] + } + ], + "source": [ + "for i in var2[\"selling_points\"]:\n", + " print(i['category'])\n", + " print(i['keywords'])\n", + " print(i['description'])" + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "id": "c89cf2eb-4f16-4dc5-90c6-df89191b4e39", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "[{'category': 'Location',\n", + " 'keywords': '군산 절골 골목 스팟',\n", + " 'description': '골목 깊숙한 전원 감성, 조용한 휴식'},\n", + " {'category': 'Concept',\n", + " 'keywords': '프라이빗 전원스테이',\n", + " 'description': '소규모 전용 공간, 맞춤형 프라이버시'},\n", + " {'category': 'Visuals',\n", + " 'keywords': '인생샷 포토존',\n", + " 'description': '포토제닉 실내·외 컷, 밤 조명 무드'},\n", + " {'category': 'Experience',\n", + " 'keywords': '힐링 리셋 스테이',\n", + " 'description': '일상 탈출·짧은 리셋, 주말 최적'},\n", + " {'category': 'Digital',\n", + " 'keywords': 'SNS 바이럴 감성',\n", + " 'description': '쇼트폼 영상·릴스용 비주얼 중심'},\n", + " {'category': 'Booking',\n", + " 'keywords': '주말 스테이케이션 패키지',\n", + " 'description': '주말 1박 패키지, 조식·체험 옵션'}]" + ] + }, + "execution_count": 10, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "var2[\"selling_points\"]" + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "id": "231963d6-e209-41b3-8e78-2ad5d06943fe", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "['오블로모프 군산 절골', '전원감성스테이', '힐링리셋', '인생샷 명소', '주말스테이케이션']" + ] + }, + "execution_count": 11, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "var2[\"tags\"]" + ] + }, + { + "cell_type": "code", + "execution_count": 13, + "id": "f8260222-d5a2-4018-b465-a4943c82bd3f", + "metadata": {}, + "outputs": [], + "source": [ + "lyric_prompt = \"\"\"\n", + "[ROLE]\n", + "You are a content marketing expert, brand strategist, and creative songwriter\n", + "specializing in Korean pension / accommodation businesses.\n", + "You create lyrics strictly based on Brand & Marketing Intelligence analysis\n", + "and optimized for viral short-form video content.\n", + "\n", + "[INPUT]\n", + "Business Name: {customer_name}\n", + "Region: {region}\n", + "Region Details: {detail_region_info}\n", + "Brand & Marketing Intelligence Report: {marketing_intelligence_summary}\n", + "Output Language: {language}\n", + "\n", + "[INTERNAL ANALYSIS – DO NOT OUTPUT]\n", + "Internally analyze the following to guide all creative decisions:\n", + "- Core brand identity and positioning\n", + "- Emotional hooks derived from selling points\n", + "- Target audience lifestyle, desires, and travel motivation\n", + "- Regional atmosphere and symbolic imagery\n", + "- How the stay converts into “shareable moments”\n", + "- Which selling points must surface implicitly in lyrics\n", + "\n", + "[LYRICS & MUSIC CREATION TASK]\n", + "Based on the Brand & Marketing Intelligence Report for [{customer_name} ({region})], generate:\n", + "- Original promotional lyrics\n", + "- Music attributes for AI music generation (Suno-compatible prompt)\n", + "The output must be designed for VIRAL DIGITAL CONTENT\n", + "(short-form video, reels, ads).\n", + "\n", + "[LYRICS REQUIREMENTS]\n", + "Mandatory Inclusions:\n", + "- Business name\n", + "- Region name\n", + "- Promotion subject\n", + "- Promotional expressions including:\n", + "{promotional_expressions[language]}\n", + "\n", + "Content Rules:\n", + "- Lyrics must be emotionally driven, not descriptive listings\n", + "- Selling points must be IMPLIED, not explained\n", + "- Must sound natural when sung\n", + "- Must feel like a lifestyle moment, not an advertisement\n", + "\n", + "Tone & Style:\n", + "- Warm, emotional, and aspirational\n", + "- Trendy, viral-friendly phrasing\n", + "- Calm but memorable hooks\n", + "- Suitable for travel / stay-related content\n", + "\n", + "[SONG & MUSIC ATTRIBUTES – FOR SUNO PROMPT]\n", + "After the lyrics, generate a concise music prompt including:\n", + "Song mood (emotional keywords)\n", + "BPM range\n", + "Recommended genres (max 2)\n", + "Key musical motifs or instruments\n", + "Overall vibe (1 short sentence)\n", + "\n", + "[CRITICAL LANGUAGE REQUIREMENT – ABSOLUTE RULE]\n", + "ALL OUTPUT MUST BE 100% WRITTEN IN {language}.\n", + "no mixed languages\n", + "All names, places, and expressions must be in {language} \n", + "Any violation invalidates the entire output\n", + "\n", + "[OUTPUT RULES – STRICT]\n", + "{timing_rules}\n", + "8–12 lines\n", + "Full verse flow, immersive mood\n", + "\n", + "No explanations\n", + "No headings\n", + "No bullet points\n", + "No analysis\n", + "No extra text\n", + "\n", + "[FAILURE FORMAT]\n", + "If generation is impossible:\n", + "ERROR: Brief reason in English\n", + "\"\"\"\n", + "lyric_prompt_dict = {\n", + " \"prompt_variables\" :\n", + " [\n", + " \"customer_name\",\n", + " \"region\",\n", + " \"detail_region_info\",\n", + " \"marketing_intelligence_summary\",\n", + " \"language\",\n", + " \"promotional_expression_example\",\n", + " \"timing_rules\",\n", + " \n", + " ],\n", + " \"output_format\" : {\n", + " \"format\": {\n", + " \"type\": \"json_schema\",\n", + " \"name\": \"lyric\",\n", + " \"schema\": {\n", + " \"type\":\"object\",\n", + " \"properties\" : {\n", + " \"lyric\" : { \n", + " \"type\" : \"string\"\n", + " }\n", + " },\n", + " \"required\": [\"lyric\"],\n", + " \"additionalProperties\": False,\n", + " },\n", + " \"strict\": True\n", + " }\n", + " }\n", + "}" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "79edd82b-6f4c-43c7-9205-0b970afe06d7", + "metadata": {}, + "outputs": [], + "source": [ + "\n", + "with open(\"./app/utils/prompts/marketing_prompt.txt\", \"w\") as fp:\n", + " fp.write(marketing_prompt)" + ] + }, + { + "cell_type": "code", + "execution_count": 17, + "id": "65a5a2a6-06a5-4ee1-a796-406c86aefc20", + "metadata": {}, + "outputs": [], + "source": [ + "with open(\"prompts/summarize_prompt.json\", \"r\") as fp:\n", + " p = json.load(fp)" + ] + }, + { + "cell_type": "code", + "execution_count": 18, + "id": "454d920f-e9ed-4fb2-806c-75b8f7033db9", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "{'prompt_variables': ['report', 'selling_points'],\n", + " 'prompt': '\\n입력 : \\n분석 보고서\\n{report}\\n\\n셀링 포인트\\n{selling_points}\\n\\n위 분석 결과를 바탕으로, 주요 셀링 포인트를 다음 구조로 재정리하라.\\n\\n조건:\\n각 셀링 포인트는 반드시 ‘카테고리 → 태그 키워드 → 한 줄 설명’ 구조를 가질 것\\n태그 키워드는 UI 상에서 타원(oval) 형태의 시각적 태그로 사용될 것을 가정하여\\n- 3 ~ 6단어 이내\\n- 명사 또는 명사형 키워드로 작성\\n- 설명은 문장이 아닌, 짧은 ‘셀링 문구’ 형태로 작성할 것\\n- 광고·숏폼·상세페이지 어디에도 바로 재사용 가능해야 함\\n- 전체 셀링 포인트 개수는 5~7개로 제한\\n\\n출력 형식:\\n[카테고리명]\\n(태그 키워드)\\n- 한 줄 설명 문구\\n\\n예시: \\n[공간 정체성]\\n(100년 적산가옥 · 시간의 결)\\n- 하루를 ‘숙박’이 아닌 ‘체류’로 바꾸는 공간\\n\\n[입지 & 희소성]\\n(말랭이마을 · 로컬 히든플레이스)\\n- 관광지가 아닌, 군산을 아는 사람의 선택\\n\\n[프라이버시]\\n(독채 숙소 · 프라이빗 스테이)\\n- 누구의 방해도 없는 완전한 휴식 구조\\n\\n[비주얼 경쟁력]\\n(감성 인테리어 · 자연광 스폿)\\n- 찍는 순간 콘텐츠가 되는 공간 설계\\n\\n[타깃 최적화]\\n(커플 · 소규모 여행)\\n- 둘에게 가장 이상적인 공간 밀도\\n\\n[체류 경험]\\n(아무것도 안 해도 되는 하루)\\n- 일정 없이도 만족되는 하루 루틴\\n\\n[브랜드 포지션]\\n(호텔도 펜션도 아닌 아지트)\\n- 다시 돌아오고 싶은 개인적 장소\\n ',\n", + " 'output_format': {'format': {'type': 'json_schema',\n", + " 'name': 'tags',\n", + " 'schema': {'type': 'object',\n", + " 'properties': {'category': {'type': 'string'},\n", + " 'tag_keywords': {'type': 'string'},\n", + " 'description': {'type': 'string'}},\n", + " 'required': ['category', 'tag_keywords', 'description'],\n", + " 'additionalProperties': False},\n", + " 'strict': True}}}" + ] + }, + "execution_count": 18, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "p" + ] + }, + { + "cell_type": "code", + "execution_count": 15, + "id": "c46abcda-d6a8-485e-92f1-526fb28c6b53", + "metadata": {}, + "outputs": [], + "source": [ + "marketing_prompt_dict = {\n", + " \"model\" : \"gpt-5-mini\",\n", + " \"prompt_variables\" :\n", + " [\n", + " \"customer_name\",\n", + " \"region\",\n", + " \"detail_region_info\"\n", + " ],\n", + " \"output_format\" : {\n", + " \"format\": {\n", + " \"type\": \"json_schema\",\n", + " \"name\": \"report\",\n", + " \"schema\": {\n", + " \"type\" : \"object\",\n", + " \"properties\" : {\n", + " \"report\" : {\n", + " \"type\": \"string\"\n", + " },\n", + " \"selling_points\" : {\n", + " \"type\": \"array\",\n", + " \"items\": {\n", + " \"type\": \"object\",\n", + " \"properties\" : {\n", + " \"category\" : {\"type\" : \"string\"},\n", + " \"keywords\" : {\"type\" : \"string\"},\n", + " \"description\" : {\"type\" : \"string\"}\n", + " },\n", + " \"required\": [\"category\", \"keywords\", \"description\"],\n", + " \"additionalProperties\": False,\n", + " },\n", + " },\n", + " \"tags\" : {\n", + " \"type\": \"array\",\n", + " \"items\": {\n", + " \"type\": \"string\"\n", + " },\n", + " },\n", + " },\n", + " \"required\": [\"report\", \"selling_points\", \"tags\"],\n", + " \"additionalProperties\": False,\n", + " },\n", + " \"strict\": True\n", + " }\n", + " }\n", + "}\n", + "with open(\"./app/utils/prompts/marketing_prompt.json\", \"w\") as fp:\n", + " json.dump(marketing_prompt_dict, fp, ensure_ascii=False)" + ] + }, + { + "cell_type": "code", + "execution_count": 15, + "id": "c3867dab-0c4e-46be-ad12-a9c02b5edb68", + "metadata": {}, + "outputs": [], + "source": [ + "lyric_prompt = \"\"\"\n", + "[ROLE]\n", + "You are a content marketing expert, brand strategist, and creative songwriter\n", + "specializing in Korean pension / accommodation businesses.\n", + "You create lyrics strictly based on Brand & Marketing Intelligence analysis\n", + "and optimized for viral short-form video content.\n", + "\n", + "[INPUT]\n", + "Business Name: {customer_name}\n", + "Region: {region}\n", + "Region Details: {detail_region_info}\n", + "Brand & Marketing Intelligence Report: {marketing_intelligence_summary}\n", + "Output Language: {language}\n", + "\n", + "[INTERNAL ANALYSIS – DO NOT OUTPUT]\n", + "Internally analyze the following to guide all creative decisions:\n", + "- Core brand identity and positioning\n", + "- Emotional hooks derived from selling points\n", + "- Target audience lifestyle, desires, and travel motivation\n", + "- Regional atmosphere and symbolic imagery\n", + "- How the stay converts into “shareable moments”\n", + "- Which selling points must surface implicitly in lyrics\n", + "\n", + "[LYRICS & MUSIC CREATION TASK]\n", + "Based on the Brand & Marketing Intelligence Report for [{customer_name} ({region})], generate:\n", + "- Original promotional lyrics\n", + "- Music attributes for AI music generation (Suno-compatible prompt)\n", + "The output must be designed for VIRAL DIGITAL CONTENT\n", + "(short-form video, reels, ads).\n", + "\n", + "[LYRICS REQUIREMENTS]\n", + "Mandatory Inclusions:\n", + "- Business name\n", + "- Region name\n", + "- Promotion subject\n", + "- Promotional expressions including:\n", + "{promotional_expressions[language]}\n", + "\n", + "Content Rules:\n", + "- Lyrics must be emotionally driven, not descriptive listings\n", + "- Selling points must be IMPLIED, not explained\n", + "- Must sound natural when sung\n", + "- Must feel like a lifestyle moment, not an advertisement\n", + "\n", + "Tone & Style:\n", + "- Warm, emotional, and aspirational\n", + "- Trendy, viral-friendly phrasing\n", + "- Calm but memorable hooks\n", + "- Suitable for travel / stay-related content\n", + "\n", + "[SONG & MUSIC ATTRIBUTES – FOR SUNO PROMPT]\n", + "After the lyrics, generate a concise music prompt including:\n", + "Song mood (emotional keywords)\n", + "BPM range\n", + "Recommended genres (max 2)\n", + "Key musical motifs or instruments\n", + "Overall vibe (1 short sentence)\n", + "\n", + "[CRITICAL LANGUAGE REQUIREMENT – ABSOLUTE RULE]\n", + "ALL OUTPUT MUST BE 100% WRITTEN IN {language}.\n", + "no mixed languages\n", + "All names, places, and expressions must be in {language} \n", + "Any violation invalidates the entire output\n", + "\n", + "[OUTPUT RULES – STRICT]\n", + "{timing_rules}\n", + "8–12 lines\n", + "Full verse flow, immersive mood\n", + "\n", + "No explanations\n", + "No headings\n", + "No bullet points\n", + "No analysis\n", + "No extra text\n", + "\n", + "[FAILURE FORMAT]\n", + "If generation is impossible:\n", + "ERROR: Brief reason in English\n", + "\"\"\"\n", + "with open(\"./app/utils/prompts/lyric_prompt.txt\", \"w\") as fp:\n", + " fp.write(lyric_prompt)" + ] + }, + { + "cell_type": "code", + "execution_count": 14, + "id": "5736ca4b-c379-4cae-84a9-534cad9576c7", + "metadata": {}, + "outputs": [], + "source": [ + "lyric_prompt_dict = {\n", + " \"model\" : \"gpt-5-mini\",\n", + " \"prompt_variables\" :\n", + " [\n", + " \"customer_name\",\n", + " \"region\",\n", + " \"detail_region_info\",\n", + " \"marketing_intelligence_summary\",\n", + " \"language\",\n", + " \"promotional_expression_example\",\n", + " \"timing_rules\",\n", + " \n", + " ],\n", + " \"output_format\" : {\n", + " \"format\": {\n", + " \"type\": \"json_schema\",\n", + " \"name\": \"lyric\",\n", + " \"schema\": {\n", + " \"type\":\"object\",\n", + " \"properties\" : {\n", + " \"lyric\" : { \n", + " \"type\" : \"string\"\n", + " }\n", + " },\n", + " \"required\": [\"lyric\"],\n", + " \"additionalProperties\": False,\n", + " },\n", + " \"strict\": True\n", + " }\n", + " }\n", + "}\n", + "with open(\"./app/utils/prompts/lyric_prompt.json\", \"w\") as fp:\n", + " json.dump(lyric_prompt_dict, fp, ensure_ascii=False)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "430c8914-4e6a-4b53-8903-f454e7ccb8e2", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.13.8" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/poc/timestamp_lyric/Untitled.ipynb b/poc/timestamp_lyric/Untitled.ipynb index 38e1036..eb45239 100644 --- a/poc/timestamp_lyric/Untitled.ipynb +++ b/poc/timestamp_lyric/Untitled.ipynb @@ -2,7 +2,7 @@ "cells": [ { "cell_type": "code", - "execution_count": 26, + "execution_count": 1, "id": "99398cc7-e36a-494c-88f9-b26874ff0294", "metadata": {}, "outputs": [], @@ -13,7 +13,7 @@ }, { "cell_type": "code", - "execution_count": 22, + "execution_count": 2, "id": "28c3e49b-1133-4a18-ab70-fd321b4d2734", "metadata": {}, "outputs": [], @@ -27,7 +27,7 @@ }, { "cell_type": "code", - "execution_count": 23, + "execution_count": 3, "id": "fe09b1d5-7198-4c40-9667-d7d0885c62a3", "metadata": {}, "outputs": [], @@ -40,7 +40,7 @@ }, { "cell_type": "code", - "execution_count": 24, + "execution_count": 4, "id": "81bacedc-e488-4d04-84b1-8e8a06a64565", "metadata": {}, "outputs": [], @@ -57,7 +57,7 @@ }, { "cell_type": "code", - "execution_count": 25, + "execution_count": 5, "id": "26346a13-0663-489f-98d0-69743dd8553f", "metadata": {}, "outputs": [], @@ -77,7 +77,7 @@ }, { "cell_type": "code", - "execution_count": 27, + "execution_count": 6, "id": "78db0f6b-a54c-4415-9e82-972b00fefefb", "metadata": {}, "outputs": [], @@ -87,7 +87,7 @@ }, { "cell_type": "code", - "execution_count": 2, + "execution_count": 7, "id": "44d8da8e-5a67-4125-809f-bbdb1efba55f", "metadata": {}, "outputs": [], @@ -114,7 +114,7 @@ }, { "cell_type": "code", - "execution_count": 20, + "execution_count": 8, "id": "e4e9ba7d-964f-4f29-95f3-0f8514fad7ee", "metadata": {}, "outputs": [], @@ -125,7 +125,7 @@ }, { "cell_type": "code", - "execution_count": 21, + "execution_count": 9, "id": "84a64cd5-7374-4c33-8634-6ac6ed0de425", "metadata": {}, "outputs": [ @@ -146,7 +146,7 @@ " '몸과 마음이 따뜻해지는 그런 곳이에요']" ] }, - "execution_count": 21, + "execution_count": 9, "metadata": {}, "output_type": "execute_result" } @@ -155,6 +155,663 @@ "lyric_line_list" ] }, + { + "cell_type": "code", + "execution_count": 10, + "id": "d1157cf8-03b8-47b1-a6de-02d833b9d7df", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "{'code': 200,\n", + " 'msg': 'success',\n", + " 'data': {'alignedWords': [{'word': '[Song ',\n", + " 'success': True,\n", + " 'startS': 0.79787,\n", + " 'endS': 0.80365,\n", + " 'palign': 0},\n", + " {'word': 'Duration: ',\n", + " 'success': True,\n", + " 'startS': 0.80481,\n", + " 'endS': 0.81522,\n", + " 'palign': 0},\n", + " {'word': 'Exactly 1 ',\n", + " 'success': True,\n", + " 'startS': 0.81637,\n", + " 'endS': 0.82678,\n", + " 'palign': 0},\n", + " {'word': 'minute - ',\n", + " 'success': True,\n", + " 'startS': 0.82794,\n", + " 'endS': 0.83719,\n", + " 'palign': 0},\n", + " {'word': 'Must ',\n", + " 'success': True,\n", + " 'startS': 0.83834,\n", + " 'endS': 0.84297,\n", + " 'palign': 0},\n", + " {'word': 'be ',\n", + " 'success': True,\n", + " 'startS': 0.84413,\n", + " 'endS': 0.84644,\n", + " 'palign': 0},\n", + " {'word': 'precisely 60 ',\n", + " 'success': True,\n", + " 'startS': 0.84759,\n", + " 'endS': 0.86147,\n", + " 'palign': 0},\n", + " {'word': 'seconds]\\n---\\n',\n", + " 'success': True,\n", + " 'startS': 0.86263,\n", + " 'endS': 0.8765,\n", + " 'palign': 0},\n", + " {'word': '스테이,',\n", + " 'success': True,\n", + " 'startS': 0.87766,\n", + " 'endS': 1.99468,\n", + " 'palign': 0},\n", + " {'word': '머뭄의 ',\n", + " 'success': True,\n", + " 'startS': 2.15426,\n", + " 'endS': 3.03191,\n", + " 'palign': 0},\n", + " {'word': '추억을 ',\n", + " 'success': True,\n", + " 'startS': 3.19149,\n", + " 'endS': 3.98936,\n", + " 'palign': 0},\n", + " {'word': '담아 \\n',\n", + " 'success': True,\n", + " 'startS': 4.14894,\n", + " 'endS': 5.23936,\n", + " 'palign': 0},\n", + " {'word': '군산에서의 ',\n", + " 'success': True,\n", + " 'startS': 5.34574,\n", + " 'endS': 6.38298,\n", + " 'palign': 0},\n", + " {'word': '여행을 ',\n", + " 'success': True,\n", + " 'startS': 6.54255,\n", + " 'endS': 7.10106,\n", + " 'palign': 0},\n", + " {'word': '떠나보세 \\n',\n", + " 'success': True,\n", + " 'startS': 7.22074,\n", + " 'endS': 9.17553,\n", + " 'palign': 0},\n", + " {'word': '인스타 ',\n", + " 'success': True,\n", + " 'startS': 9.25532,\n", + " 'endS': 9.73404,\n", + " 'palign': 0},\n", + " {'word': '감성 ',\n", + " 'success': True,\n", + " 'startS': 9.89362,\n", + " 'endS': 10.21277,\n", + " 'palign': 0},\n", + " {'word': '가득한 ',\n", + " 'success': True,\n", + " 'startS': 10.37234,\n", + " 'endS': 10.77128,\n", + " 'palign': 0},\n", + " {'word': '사진같은 ',\n", + " 'success': True,\n", + " 'startS': 10.93085,\n", + " 'endS': 11.80851,\n", + " 'palign': 0},\n", + " {'word': '하루, \\n',\n", + " 'success': True,\n", + " 'startS': 11.96809,\n", + " 'endS': 12.70612,\n", + " 'palign': 0},\n", + " {'word': '힐링의 ',\n", + " 'success': True,\n", + " 'startS': 12.76596,\n", + " 'endS': 13.48404,\n", + " 'palign': 0},\n", + " {'word': '시간, ',\n", + " 'success': True,\n", + " 'startS': 13.60372,\n", + " 'endS': 14.24202,\n", + " 'palign': 0},\n", + " {'word': '감성 ',\n", + " 'success': True,\n", + " 'startS': 14.3617,\n", + " 'endS': 14.76064,\n", + " 'palign': 0},\n", + " {'word': '숙소에서의 ',\n", + " 'success': True,\n", + " 'startS': 14.92021,\n", + " 'endS': 15.87766,\n", + " 'palign': 0},\n", + " {'word': '휴식\\n\\n',\n", + " 'success': True,\n", + " 'startS': 16.03723,\n", + " 'endS': 17.91223,\n", + " 'palign': 0},\n", + " {'word': '은파호수공원의 ',\n", + " 'success': True,\n", + " 'startS': 18.03191,\n", + " 'endS': 19.14894,\n", + " 'palign': 0},\n", + " {'word': '자연 ',\n", + " 'success': True,\n", + " 'startS': 19.30851,\n", + " 'endS': 19.78723,\n", + " 'palign': 0},\n", + " {'word': '속, \\n',\n", + " 'success': True,\n", + " 'startS': 19.94681,\n", + " 'endS': 20.76064,\n", + " 'palign': 0},\n", + " {'word': '시간이 ',\n", + " 'success': True,\n", + " 'startS': 20.79255,\n", + " 'endS': 20.98404,\n", + " 'palign': 0},\n", + " {'word': '멈춘 ',\n", + " 'success': True,\n", + " 'startS': 21.14362,\n", + " 'endS': 21.54255,\n", + " 'palign': 0},\n", + " {'word': '듯한 ',\n", + " 'success': True,\n", + " 'startS': 21.70213,\n", + " 'endS': 22.42021,\n", + " 'palign': 0},\n", + " {'word': '절골길을 ',\n", + " 'success': True,\n", + " 'startS': 22.57979,\n", + " 'endS': 23.29787,\n", + " 'palign': 0},\n", + " {'word': '걸어봐요 \\n',\n", + " 'success': True,\n", + " 'startS': 23.45745,\n", + " 'endS': 26.6707,\n", + " 'palign': 0},\n", + " {'word': 'Instagram ',\n", + " 'success': True,\n", + " 'startS': 26.72147,\n", + " 'endS': 27.12766,\n", + " 'palign': 0},\n", + " {'word': 'vibes, ',\n", + " 'success': True,\n", + " 'startS': 27.22739,\n", + " 'endS': 27.96543,\n", + " 'palign': 0},\n", + " {'word': '그림 ',\n", + " 'success': True,\n", + " 'startS': 28.00532,\n", + " 'endS': 28.16489,\n", + " 'palign': 0},\n", + " {'word': '같은 ',\n", + " 'success': True,\n", + " 'startS': 28.32447,\n", + " 'endS': 28.48404,\n", + " 'palign': 0},\n", + " {'word': '힐링 ',\n", + " 'success': True,\n", + " 'startS': 28.64362,\n", + " 'endS': 28.88298,\n", + " 'palign': 0},\n", + " {'word': '장소, \\n',\n", + " 'success': True,\n", + " 'startS': 29.04255,\n", + " 'endS': 29.6609,\n", + " 'palign': 0},\n", + " {'word': '잊지 ',\n", + " 'success': True,\n", + " 'startS': 29.68085,\n", + " 'endS': 29.84043,\n", + " 'palign': 0},\n", + " {'word': '못할 ',\n", + " 'success': True,\n", + " 'startS': 30.0,\n", + " 'endS': 30.23936,\n", + " 'palign': 0},\n", + " {'word': '여행 ',\n", + " 'success': True,\n", + " 'startS': 30.39894,\n", + " 'endS': 30.55851,\n", + " 'palign': 0},\n", + " {'word': '스토리 ',\n", + " 'success': True,\n", + " 'startS': 30.6383,\n", + " 'endS': 30.95745,\n", + " 'palign': 0},\n", + " {'word': '만들어지네\\n\\n',\n", + " 'success': True,\n", + " 'startS': 31.11702,\n", + " 'endS': 33.39096,\n", + " 'palign': 0},\n", + " {'word': '넷이서 ',\n", + " 'success': True,\n", + " 'startS': 33.51064,\n", + " 'endS': 34.94681,\n", + " 'palign': 0},\n", + " {'word': '웃고 ',\n", + " 'success': True,\n", + " 'startS': 35.10638,\n", + " 'endS': 36.38298,\n", + " 'palign': 0},\n", + " {'word': '떠들던 ',\n", + " 'success': True,\n", + " 'startS': 36.54255,\n", + " 'endS': 37.02128,\n", + " 'palign': 0},\n", + " {'word': '그 ',\n", + " 'success': True,\n", + " 'startS': 37.18085,\n", + " 'endS': 37.18085,\n", + " 'palign': 0},\n", + " {'word': '날의 ',\n", + " 'success': True,\n", + " 'startS': 37.34043,\n", + " 'endS': 37.65957,\n", + " 'palign': 0},\n", + " {'word': '사진 ',\n", + " 'success': True,\n", + " 'startS': 37.81915,\n", + " 'endS': 38.29787,\n", + " 'palign': 0},\n", + " {'word': '속, \\n',\n", + " 'success': True,\n", + " 'startS': 38.45745,\n", + " 'endS': 38.93617,\n", + " 'palign': 0},\n", + " {'word': '그 ',\n", + " 'success': True,\n", + " 'startS': 39.01596,\n", + " 'endS': 39.01596,\n", + " 'palign': 0},\n", + " {'word': '순간 ',\n", + " 'success': True,\n", + " 'startS': 39.17553,\n", + " 'endS': 39.73404,\n", + " 'palign': 0},\n", + " {'word': '훌쩍 ',\n", + " 'success': True,\n", + " 'startS': 39.89362,\n", + " 'endS': 40.37234,\n", + " 'palign': 0},\n", + " {'word': '떠나볼까요, ',\n", + " 'success': True,\n", + " 'startS': 40.49202,\n", + " 'endS': 41.48936,\n", + " 'palign': 0},\n", + " {'word': '새로운 ',\n", + " 'success': True,\n", + " 'startS': 41.56915,\n", + " 'endS': 41.8883,\n", + " 'palign': 0},\n", + " {'word': '길로 \\n',\n", + " 'success': True,\n", + " 'startS': 42.04787,\n", + " 'endS': 43.61702,\n", + " 'palign': 0},\n", + " {'word': '스테이,',\n", + " 'success': True,\n", + " 'startS': 43.7234,\n", + " 'endS': 45.23936,\n", + " 'palign': 0},\n", + " {'word': '머뭄이 ',\n", + " 'success': True,\n", + " 'startS': 45.31915,\n", + " 'endS': 46.03723,\n", + " 'palign': 0},\n", + " {'word': '준비한 ',\n", + " 'success': True,\n", + " 'startS': 46.15691,\n", + " 'endS': 46.35638,\n", + " 'palign': 0},\n", + " {'word': '특별한 ',\n", + " 'success': True,\n", + " 'startS': 46.51596,\n", + " 'endS': 47.39362,\n", + " 'palign': 0},\n", + " {'word': '여행지 \\n',\n", + " 'success': True,\n", + " 'startS': 47.55319,\n", + " 'endS': 48.45745,\n", + " 'palign': 0},\n", + " {'word': '몸과 ',\n", + " 'success': True,\n", + " 'startS': 48.51064,\n", + " 'endS': 48.75,\n", + " 'palign': 0},\n", + " {'word': '마음이 ',\n", + " 'success': True,\n", + " 'startS': 48.86968,\n", + " 'endS': 49.3883,\n", + " 'palign': 0},\n", + " {'word': '따뜻해지는 ',\n", + " 'success': True,\n", + " 'startS': 49.54787,\n", + " 'endS': 50.74468,\n", + " 'palign': 0},\n", + " {'word': '그런 ',\n", + " 'success': True,\n", + " 'startS': 50.90426,\n", + " 'endS': 51.14362,\n", + " 'palign': 0},\n", + " {'word': '곳이에요 \\n---\\n\\n',\n", + " 'success': True,\n", + " 'startS': 51.30319,\n", + " 'endS': 52.42021,\n", + " 'palign': 0}],\n", + " 'waveformData': [0.0024,\n", + " 0.00145,\n", + " 0.00026,\n", + " 0.05008,\n", + " 0.08668,\n", + " 0.14601,\n", + " 0.08767,\n", + " 0.00433,\n", + " 0.00227,\n", + " 0.08147,\n", + " 0.15985,\n", + " 0.13624,\n", + " 0.12829,\n", + " 0.08064,\n", + " 0.05752,\n", + " 0.12311,\n", + " 0.12957,\n", + " 0.14846,\n", + " 0.11844,\n", + " 0.10117,\n", + " 0.13034,\n", + " 0.11055,\n", + " 0.07709,\n", + " 0.05685,\n", + " 0.02501,\n", + " 0.14656,\n", + " 0.10793,\n", + " 0.16057,\n", + " 0.13941,\n", + " 0.12291,\n", + " 0.1357,\n", + " 0.11927,\n", + " 0.14904,\n", + " 0.10628,\n", + " 0.06977,\n", + " 0.15169,\n", + " 0.16483,\n", + " 0.17301,\n", + " 0.15712,\n", + " 0.16704,\n", + " 0.13966,\n", + " 0.14572,\n", + " 0.05095,\n", + " 0.04091,\n", + " 0.03502,\n", + " 0.12531,\n", + " 0.09904,\n", + " 0.15637,\n", + " 0.13587,\n", + " 0.11911,\n", + " 0.12038,\n", + " 0.12722,\n", + " 0.10296,\n", + " 0.10861,\n", + " 0.10846,\n", + " 0.14517,\n", + " 0.14039,\n", + " 0.12067,\n", + " 0.11819,\n", + " 0.10695,\n", + " 0.12087,\n", + " 0.07742,\n", + " 0.02993,\n", + " 0.15023,\n", + " 0.11843,\n", + " 0.1133,\n", + " 0.13363,\n", + " 0.08305,\n", + " 0.05272,\n", + " 0.04856,\n", + " 0.12271,\n", + " 0.13456,\n", + " 0.1017,\n", + " 0.05826,\n", + " 0.06904,\n", + " 0.11278,\n", + " 0.17128,\n", + " 0.11561,\n", + " 0.12541,\n", + " 0.07905,\n", + " 0.09782,\n", + " 0.07438,\n", + " 0.03867,\n", + " 0.03002,\n", + " 0.03009,\n", + " 0.03212,\n", + " 0.03605,\n", + " 0.03236,\n", + " 0.03423,\n", + " 0.10645,\n", + " 0.15616,\n", + " 0.15665,\n", + " 0.19944,\n", + " 0.18949,\n", + " 0.16836,\n", + " 0.35886,\n", + " 0.2726,\n", + " 0.22786,\n", + " 0.15631,\n", + " 0.13722,\n", + " 0.15044,\n", + " 0.09713,\n", + " 0.13903,\n", + " 0.1424,\n", + " 0.21145,\n", + " 0.31825,\n", + " 0.26534,\n", + " 0.18763,\n", + " 0.16866,\n", + " 0.09073,\n", + " 0.16109,\n", + " 0.09477,\n", + " 0.19386,\n", + " 0.1828,\n", + " 0.22564,\n", + " 0.29365,\n", + " 0.29561,\n", + " 0.21323,\n", + " 0.08006,\n", + " 0.13708,\n", + " 0.17323,\n", + " 0.14241,\n", + " 0.05998,\n", + " 0.0332,\n", + " 0.18112,\n", + " 0.25064,\n", + " 0.32029,\n", + " 0.18728,\n", + " 0.07016,\n", + " 0.07271,\n", + " 0.14213,\n", + " 0.18954,\n", + " 0.14502,\n", + " 0.24928,\n", + " 0.14018,\n", + " 0.18588,\n", + " 0.3885,\n", + " 0.22415,\n", + " 0.0822,\n", + " 0.11881,\n", + " 0.14206,\n", + " 0.18143,\n", + " 0.13677,\n", + " 0.13571,\n", + " 0.14057,\n", + " 0.12687,\n", + " 0.39083,\n", + " 0.21553,\n", + " 0.10091,\n", + " 0.12723,\n", + " 0.10899,\n", + " 0.2095,\n", + " 0.12927,\n", + " 0.235,\n", + " 0.17007,\n", + " 0.17536,\n", + " 0.37607,\n", + " 0.20952,\n", + " 0.13125,\n", + " 0.07863,\n", + " 0.14181,\n", + " 0.20156,\n", + " 0.11611,\n", + " 0.03968,\n", + " 0.11121,\n", + " 0.17693,\n", + " 0.36549,\n", + " 0.21739,\n", + " 0.22707,\n", + " 0.19729,\n", + " 0.18738,\n", + " 0.23218,\n", + " 0.2207,\n", + " 0.23844,\n", + " 0.23672,\n", + " 0.25431,\n", + " 0.18791,\n", + " 0.20719,\n", + " 0.19892,\n", + " 0.17839,\n", + " 0.1102,\n", + " 0.10608,\n", + " 0.14977,\n", + " 0.20302,\n", + " 0.154,\n", + " 0.17731,\n", + " 0.1662,\n", + " 0.17365,\n", + " 0.12506,\n", + " 0.14131,\n", + " 0.13431,\n", + " 0.12933,\n", + " 0.18372,\n", + " 0.15119,\n", + " 0.13249,\n", + " 0.1538,\n", + " 0.15832,\n", + " 0.22099,\n", + " 0.12311,\n", + " 0.15133,\n", + " 0.14606,\n", + " 0.09548,\n", + " 0.16636,\n", + " 0.12795,\n", + " 0.10843,\n", + " 0.14071,\n", + " 0.09755,\n", + " 0.19762,\n", + " 0.18621,\n", + " 0.1541,\n", + " 0.1645,\n", + " 0.20159,\n", + " 0.18791,\n", + " 0.25831,\n", + " 0.20984,\n", + " 0.20549,\n", + " 0.27064,\n", + " 0.34967,\n", + " 0.26562,\n", + " 0.22725,\n", + " 0.19375,\n", + " 0.21994,\n", + " 0.22239,\n", + " 0.22568,\n", + " 0.26186,\n", + " 0.24915,\n", + " 0.26614,\n", + " 0.37754,\n", + " 0.25693,\n", + " 0.27504,\n", + " 0.2631,\n", + " 0.21398,\n", + " 0.22903,\n", + " 0.12057,\n", + " 0.18403,\n", + " 0.23384,\n", + " 0.24452,\n", + " 0.36614,\n", + " 0.26112,\n", + " 0.22958,\n", + " 0.21953,\n", + " 0.22764,\n", + " 0.22412,\n", + " 0.20514,\n", + " 0.25245,\n", + " 0.22405,\n", + " 0.18552,\n", + " 0.37504,\n", + " 0.22006,\n", + " 0.20789,\n", + " 0.19554,\n", + " 0.19651,\n", + " 0.21981,\n", + " 0.15264,\n", + " 0.28411,\n", + " 0.19417,\n", + " 0.11382,\n", + " 0.16134,\n", + " 0.17663,\n", + " 0.07858,\n", + " 0.02706,\n", + " 0.02184,\n", + " 0.08633,\n", + " 0.04758,\n", + " 0.07086,\n", + " 0.07412,\n", + " 0.07322,\n", + " 0.07777,\n", + " 0.07332,\n", + " 0.04565,\n", + " 0.06082,\n", + " 0.05819,\n", + " 0.08265,\n", + " 0.0666,\n", + " 0.06084,\n", + " 0.05344,\n", + " 0.05126,\n", + " 0.05003,\n", + " 0.05129,\n", + " 0.04853,\n", + " 0.04825,\n", + " 0.04505,\n", + " 0.0591,\n", + " 0.08663,\n", + " 0.04147,\n", + " 0.03333,\n", + " 0.02818,\n", + " 0.02059,\n", + " 0.02719,\n", + " 0.02584,\n", + " 0.02731,\n", + " 0.03603,\n", + " 0.04302,\n", + " 0.04595,\n", + " 0.04307,\n", + " 0.05182,\n", + " 0.07637,\n", + " 0.10123],\n", + " 'hootCer': 0.41935483870967744,\n", + " 'isStreamed': False}}" + ] + }, + "execution_count": 10, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "data" + ] + }, { "cell_type": "code", "execution_count": null, @@ -167,7 +824,360 @@ }, { "cell_type": "code", - "execution_count": 35, + "execution_count": 12, + "id": "7dd7bda1-e2ff-4987-88f6-5656ebc8d224", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "[{'word': '[Song ',\n", + " 'success': True,\n", + " 'startS': 0.79787,\n", + " 'endS': 0.80365,\n", + " 'palign': 0},\n", + " {'word': 'Duration: ',\n", + " 'success': True,\n", + " 'startS': 0.80481,\n", + " 'endS': 0.81522,\n", + " 'palign': 0},\n", + " {'word': 'Exactly 1 ',\n", + " 'success': True,\n", + " 'startS': 0.81637,\n", + " 'endS': 0.82678,\n", + " 'palign': 0},\n", + " {'word': 'minute - ',\n", + " 'success': True,\n", + " 'startS': 0.82794,\n", + " 'endS': 0.83719,\n", + " 'palign': 0},\n", + " {'word': 'Must ',\n", + " 'success': True,\n", + " 'startS': 0.83834,\n", + " 'endS': 0.84297,\n", + " 'palign': 0},\n", + " {'word': 'be ',\n", + " 'success': True,\n", + " 'startS': 0.84413,\n", + " 'endS': 0.84644,\n", + " 'palign': 0},\n", + " {'word': 'precisely 60 ',\n", + " 'success': True,\n", + " 'startS': 0.84759,\n", + " 'endS': 0.86147,\n", + " 'palign': 0},\n", + " {'word': 'seconds]\\n---\\n',\n", + " 'success': True,\n", + " 'startS': 0.86263,\n", + " 'endS': 0.8765,\n", + " 'palign': 0},\n", + " {'word': '스테이,',\n", + " 'success': True,\n", + " 'startS': 0.87766,\n", + " 'endS': 1.99468,\n", + " 'palign': 0},\n", + " {'word': '머뭄의 ',\n", + " 'success': True,\n", + " 'startS': 2.15426,\n", + " 'endS': 3.03191,\n", + " 'palign': 0},\n", + " {'word': '추억을 ',\n", + " 'success': True,\n", + " 'startS': 3.19149,\n", + " 'endS': 3.98936,\n", + " 'palign': 0},\n", + " {'word': '담아 \\n',\n", + " 'success': True,\n", + " 'startS': 4.14894,\n", + " 'endS': 5.23936,\n", + " 'palign': 0},\n", + " {'word': '군산에서의 ',\n", + " 'success': True,\n", + " 'startS': 5.34574,\n", + " 'endS': 6.38298,\n", + " 'palign': 0},\n", + " {'word': '여행을 ',\n", + " 'success': True,\n", + " 'startS': 6.54255,\n", + " 'endS': 7.10106,\n", + " 'palign': 0},\n", + " {'word': '떠나보세 \\n',\n", + " 'success': True,\n", + " 'startS': 7.22074,\n", + " 'endS': 9.17553,\n", + " 'palign': 0},\n", + " {'word': '인스타 ',\n", + " 'success': True,\n", + " 'startS': 9.25532,\n", + " 'endS': 9.73404,\n", + " 'palign': 0},\n", + " {'word': '감성 ',\n", + " 'success': True,\n", + " 'startS': 9.89362,\n", + " 'endS': 10.21277,\n", + " 'palign': 0},\n", + " {'word': '가득한 ',\n", + " 'success': True,\n", + " 'startS': 10.37234,\n", + " 'endS': 10.77128,\n", + " 'palign': 0},\n", + " {'word': '사진같은 ',\n", + " 'success': True,\n", + " 'startS': 10.93085,\n", + " 'endS': 11.80851,\n", + " 'palign': 0},\n", + " {'word': '하루, \\n',\n", + " 'success': True,\n", + " 'startS': 11.96809,\n", + " 'endS': 12.70612,\n", + " 'palign': 0},\n", + " {'word': '힐링의 ',\n", + " 'success': True,\n", + " 'startS': 12.76596,\n", + " 'endS': 13.48404,\n", + " 'palign': 0},\n", + " {'word': '시간, ',\n", + " 'success': True,\n", + " 'startS': 13.60372,\n", + " 'endS': 14.24202,\n", + " 'palign': 0},\n", + " {'word': '감성 ',\n", + " 'success': True,\n", + " 'startS': 14.3617,\n", + " 'endS': 14.76064,\n", + " 'palign': 0},\n", + " {'word': '숙소에서의 ',\n", + " 'success': True,\n", + " 'startS': 14.92021,\n", + " 'endS': 15.87766,\n", + " 'palign': 0},\n", + " {'word': '휴식\\n\\n',\n", + " 'success': True,\n", + " 'startS': 16.03723,\n", + " 'endS': 17.91223,\n", + " 'palign': 0},\n", + " {'word': '은파호수공원의 ',\n", + " 'success': True,\n", + " 'startS': 18.03191,\n", + " 'endS': 19.14894,\n", + " 'palign': 0},\n", + " {'word': '자연 ',\n", + " 'success': True,\n", + " 'startS': 19.30851,\n", + " 'endS': 19.78723,\n", + " 'palign': 0},\n", + " {'word': '속, \\n',\n", + " 'success': True,\n", + " 'startS': 19.94681,\n", + " 'endS': 20.76064,\n", + " 'palign': 0},\n", + " {'word': '시간이 ',\n", + " 'success': True,\n", + " 'startS': 20.79255,\n", + " 'endS': 20.98404,\n", + " 'palign': 0},\n", + " {'word': '멈춘 ',\n", + " 'success': True,\n", + " 'startS': 21.14362,\n", + " 'endS': 21.54255,\n", + " 'palign': 0},\n", + " {'word': '듯한 ',\n", + " 'success': True,\n", + " 'startS': 21.70213,\n", + " 'endS': 22.42021,\n", + " 'palign': 0},\n", + " {'word': '절골길을 ',\n", + " 'success': True,\n", + " 'startS': 22.57979,\n", + " 'endS': 23.29787,\n", + " 'palign': 0},\n", + " {'word': '걸어봐요 \\n',\n", + " 'success': True,\n", + " 'startS': 23.45745,\n", + " 'endS': 26.6707,\n", + " 'palign': 0},\n", + " {'word': 'Instagram ',\n", + " 'success': True,\n", + " 'startS': 26.72147,\n", + " 'endS': 27.12766,\n", + " 'palign': 0},\n", + " {'word': 'vibes, ',\n", + " 'success': True,\n", + " 'startS': 27.22739,\n", + " 'endS': 27.96543,\n", + " 'palign': 0},\n", + " {'word': '그림 ',\n", + " 'success': True,\n", + " 'startS': 28.00532,\n", + " 'endS': 28.16489,\n", + " 'palign': 0},\n", + " {'word': '같은 ',\n", + " 'success': True,\n", + " 'startS': 28.32447,\n", + " 'endS': 28.48404,\n", + " 'palign': 0},\n", + " {'word': '힐링 ',\n", + " 'success': True,\n", + " 'startS': 28.64362,\n", + " 'endS': 28.88298,\n", + " 'palign': 0},\n", + " {'word': '장소, \\n',\n", + " 'success': True,\n", + " 'startS': 29.04255,\n", + " 'endS': 29.6609,\n", + " 'palign': 0},\n", + " {'word': '잊지 ',\n", + " 'success': True,\n", + " 'startS': 29.68085,\n", + " 'endS': 29.84043,\n", + " 'palign': 0},\n", + " {'word': '못할 ',\n", + " 'success': True,\n", + " 'startS': 30.0,\n", + " 'endS': 30.23936,\n", + " 'palign': 0},\n", + " {'word': '여행 ',\n", + " 'success': True,\n", + " 'startS': 30.39894,\n", + " 'endS': 30.55851,\n", + " 'palign': 0},\n", + " {'word': '스토리 ',\n", + " 'success': True,\n", + " 'startS': 30.6383,\n", + " 'endS': 30.95745,\n", + " 'palign': 0},\n", + " {'word': '만들어지네\\n\\n',\n", + " 'success': True,\n", + " 'startS': 31.11702,\n", + " 'endS': 33.39096,\n", + " 'palign': 0},\n", + " {'word': '넷이서 ',\n", + " 'success': True,\n", + " 'startS': 33.51064,\n", + " 'endS': 34.94681,\n", + " 'palign': 0},\n", + " {'word': '웃고 ',\n", + " 'success': True,\n", + " 'startS': 35.10638,\n", + " 'endS': 36.38298,\n", + " 'palign': 0},\n", + " {'word': '떠들던 ',\n", + " 'success': True,\n", + " 'startS': 36.54255,\n", + " 'endS': 37.02128,\n", + " 'palign': 0},\n", + " {'word': '그 ',\n", + " 'success': True,\n", + " 'startS': 37.18085,\n", + " 'endS': 37.18085,\n", + " 'palign': 0},\n", + " {'word': '날의 ',\n", + " 'success': True,\n", + " 'startS': 37.34043,\n", + " 'endS': 37.65957,\n", + " 'palign': 0},\n", + " {'word': '사진 ',\n", + " 'success': True,\n", + " 'startS': 37.81915,\n", + " 'endS': 38.29787,\n", + " 'palign': 0},\n", + " {'word': '속, \\n',\n", + " 'success': True,\n", + " 'startS': 38.45745,\n", + " 'endS': 38.93617,\n", + " 'palign': 0},\n", + " {'word': '그 ',\n", + " 'success': True,\n", + " 'startS': 39.01596,\n", + " 'endS': 39.01596,\n", + " 'palign': 0},\n", + " {'word': '순간 ',\n", + " 'success': True,\n", + " 'startS': 39.17553,\n", + " 'endS': 39.73404,\n", + " 'palign': 0},\n", + " {'word': '훌쩍 ',\n", + " 'success': True,\n", + " 'startS': 39.89362,\n", + " 'endS': 40.37234,\n", + " 'palign': 0},\n", + " {'word': '떠나볼까요, ',\n", + " 'success': True,\n", + " 'startS': 40.49202,\n", + " 'endS': 41.48936,\n", + " 'palign': 0},\n", + " {'word': '새로운 ',\n", + " 'success': True,\n", + " 'startS': 41.56915,\n", + " 'endS': 41.8883,\n", + " 'palign': 0},\n", + " {'word': '길로 \\n',\n", + " 'success': True,\n", + " 'startS': 42.04787,\n", + " 'endS': 43.61702,\n", + " 'palign': 0},\n", + " {'word': '스테이,',\n", + " 'success': True,\n", + " 'startS': 43.7234,\n", + " 'endS': 45.23936,\n", + " 'palign': 0},\n", + " {'word': '머뭄이 ',\n", + " 'success': True,\n", + " 'startS': 45.31915,\n", + " 'endS': 46.03723,\n", + " 'palign': 0},\n", + " {'word': '준비한 ',\n", + " 'success': True,\n", + " 'startS': 46.15691,\n", + " 'endS': 46.35638,\n", + " 'palign': 0},\n", + " {'word': '특별한 ',\n", + " 'success': True,\n", + " 'startS': 46.51596,\n", + " 'endS': 47.39362,\n", + " 'palign': 0},\n", + " {'word': '여행지 \\n',\n", + " 'success': True,\n", + " 'startS': 47.55319,\n", + " 'endS': 48.45745,\n", + " 'palign': 0},\n", + " {'word': '몸과 ',\n", + " 'success': True,\n", + " 'startS': 48.51064,\n", + " 'endS': 48.75,\n", + " 'palign': 0},\n", + " {'word': '마음이 ',\n", + " 'success': True,\n", + " 'startS': 48.86968,\n", + " 'endS': 49.3883,\n", + " 'palign': 0},\n", + " {'word': '따뜻해지는 ',\n", + " 'success': True,\n", + " 'startS': 49.54787,\n", + " 'endS': 50.74468,\n", + " 'palign': 0},\n", + " {'word': '그런 ',\n", + " 'success': True,\n", + " 'startS': 50.90426,\n", + " 'endS': 51.14362,\n", + " 'palign': 0},\n", + " {'word': '곳이에요 \\n---\\n\\n',\n", + " 'success': True,\n", + " 'startS': 51.30319,\n", + " 'endS': 52.42021,\n", + " 'palign': 0}]" + ] + }, + "execution_count": 12, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [] + }, + { + "cell_type": "code", + "execution_count": 11, "id": "a8df83b4-99ef-4751-8c98-e5423c5c2494", "metadata": {}, "outputs": [], @@ -192,7 +1202,26 @@ ], "source": [ "alignment_lyric = {}\n", - "lyric_index = 0 \n", + "lyric_index = 0 \n", + "word_index = 0\n", + "\n", + "word_start_flag = False\n", + "\n", + "while (lyric_index < len(lyric_line_list) and word_index < len(aligned_words)):\n", + " current_lyric_block = lyric_line_list[lyric_index]\n", + " current_word = aligned_words[word_index]\n", + " if not word_start_flag:\n", + " if \"---\" in current_word:\n", + " word_start_flag = True\n", + " try:\n", + " aligned_words[word_index + 1].strip(\"\\n\") in current_lyric_block\n", + " except:\n", + " print(\"matching failed\")\n", + " break;\n", + "\n", + " \n", + " \n", + " \n", "for aligned_word in aligned_words:\n", " if not aligned_word['succsess']:\n", " continue\n",