# -*- coding: utf-8 -*- import os, io, json, pathlib, re, random import pandas as pd import streamlit as st import torch import torch.nn.functional as F from collections import defaultdict from datetime import datetime from huggingface_hub import hf_hub_download from sentence_transformers import SentenceTransformer, util from transformers import AutoTokenizer, AutoModelForSequenceClassification from css import log_and_render # ──────────────────────────────── 캐시/데이터셋 설정 ──────────────────────────────── HOME = pathlib.Path.home() # ✅ ENV가 있으면 따르고, 없으면 홈 밑 .cache/hf-cache 사용 CACHE_DIR = os.getenv("TRANSFORMERS_CACHE") or os.path.expanduser("~/.cache/hf-cache") os.makedirs(CACHE_DIR, exist_ok=True) HF_DATASET_REPO = os.getenv("HF_DATASET_REPO", "emisdfde/moai-travel-data") HF_DATASET_REV = os.getenv("HF_DATASET_REV", "main") def _is_pointer_bytes(b: bytes) -> bool: head = b[:2048].decode(errors="ignore").lower() return ( "version https://git-lfs.github.com/spec/v1" in head or "git-lfs" in head or "xet" in head or "pointer size" in head ) def _read_csv_bytes(b: bytes) -> pd.DataFrame: try: return pd.read_csv(io.BytesIO(b), encoding="utf-8") except UnicodeDecodeError: return pd.read_csv(io.BytesIO(b), encoding="cp949") def load_csv_smart(local_path: str, hub_filename: str | None = None, repo_id: str = HF_DATASET_REPO, repo_type: str = "dataset", revision: str = HF_DATASET_REV) -> pd.DataFrame: if hub_filename is None: hub_filename = os.path.basename(local_path) if os.path.exists(local_path): with open(local_path, "rb") as f: data = f.read() if not _is_pointer_bytes(data): try: return pd.read_csv(io.BytesIO(data), encoding="utf-8") except UnicodeDecodeError: return pd.read_csv(io.BytesIO(data), encoding="cp949") cached = hf_hub_download(repo_id=repo_id, filename=hub_filename, repo_type=repo_type, revision=revision) try: return pd.read_csv(cached, encoding="utf-8") except UnicodeDecodeError: return pd.read_csv(cached, encoding="cp949") # ──────────────────────────────── 전역 데이터 컨테이너 (지연 초기화) ──────────────────────────────── travel_df = festival_df = external_score_df = weather_df = package_df = master_df = None countries, cities = [], [] theme_title_phrases = {} def _strip_columns(df: pd.DataFrame | None) -> pd.DataFrame | None: if df is not None and hasattr(df, "columns"): df.columns = df.columns.str.strip() return df def init_datasets(*, travel_df: pd.DataFrame, festival_df: pd.DataFrame, external_score_df: pd.DataFrame, weather_df: pd.DataFrame, package_df: pd.DataFrame, master_df: pd.DataFrame, theme_title_phrases: dict | None = None): """app.py에서 데이터 로드가 끝난 뒤 딱 한 번 호출""" globals()["travel_df"] = _strip_columns(travel_df.copy()) globals()["festival_df"] = _strip_columns(festival_df.copy()) globals()["external_score_df"] = _strip_columns(external_score_df.copy()) globals()["weather_df"] = _strip_columns(weather_df.copy()) globals()["package_df"] = _strip_columns(package_df.copy()) globals()["master_df"] = _strip_columns(master_df.copy()) if theme_title_phrases is not None: globals()["theme_title_phrases"] = theme_title_phrases # 필수 컬럼 확인 req = ["여행나라", "여행도시", "여행지"] miss = [c for c in req if c not in globals()["travel_df"].columns] if miss: raise KeyError(f"travel_df 필수 컬럼 누락: {miss} / 실제: {list(globals()['travel_df'].columns)}") # 파생 목록 global countries, cities countries = sorted(globals()["travel_df"]["여행나라"].dropna().unique().tolist()) cities = sorted(globals()["travel_df"]["여행도시"].dropna().unique().tolist()) def _assert_ready(): if globals()["travel_df"] is None: raise RuntimeError("chat_a.init_datasets(...)를 먼저 호출해주세요.") # ──────────────────────────────── 모델 로더 (캐시/권한 안전) ──────────────────────────────── @st.cache_resource(show_spinner=False) def load_tokenizer(): return AutoTokenizer.from_pretrained("hun3359/klue-bert-base-sentiment", cache_dir=CACHE_DIR) @st.cache_resource(show_spinner=False) def load_sentiment_model(): # 토크나이저는 load_tokenizer 함수가 담당하므로 여기서는 모델만 로드 model = AutoModelForSequenceClassification.from_pretrained( "hun3359/klue-bert-base-sentiment", cache_dir=CACHE_DIR ) model.eval() return model # 모델만 반환하도록 수정! @st.cache_resource(show_spinner=False) def load_sbert_model(): return SentenceTransformer("jhgan/ko-sroberta-multitask", cache_folder=CACHE_DIR) def detect_location_filter(text, intent_score=None): def in_text_exact(word): return word in text master_cities_list = master_df["여행도시"].dropna().unique() master_countries_list = master_df["여행나라"].dropna().unique() found_city = next((c for c in master_cities_list if c in text), None) found_country = next((c for c in master_countries_list if c in text), None) # 1. 이름조차 찾지 못했다면 intent_score 확인 if not found_city and not found_country: if intent_score is not None and intent_score >= 0.70: return None, None, "intent" else: return None, None, "emotion" # 2. 명확한 도시/나라가 있을 경우 → region if found_city and not found_country: match = master_df[master_df["여행도시"] == found_city] if not match.empty: found_country = match.iloc[0]["여행나라"] is_city_recommendable = found_city in cities if found_city else False is_country_recommendable = found_country in countries if found_country else False if is_city_recommendable or is_country_recommendable: return found_country, found_city, "region" return found_country, found_city, "unknown" # -------------------- 감정 키워드 설정 -------------------- klue_emotions = { 0: "분노", 1: "툴툴대는", 2: "좌절한", 3: "짜증내는", 4: "방어적인", 5: "악의적인", 6: "안달하는", 7: "구역질 나는", 8: "노여워하는", 9: "성가신", 10: "슬픔", 11: "실망한", 12: "비통한", 13: "후회되는", 14: "우울한", 15: "마비된", 16: "염세적인", 17: "눈물이 나는", 18: "낙담한", 19: "환멸을 느끼는", 20: "불안", 21: "두려운", 22: "스트레스 받는", 23: "취약한", 24: "혼란스러운", 25: "당혹스러운", 26: "회의적인", 27: "걱정스러운", 28: "조심스러운", 29: "초조한", 30: "상처", 31: "질투하는", 32: "배신당한", 33: "고립된", 34: "충격 받은", 35: "가난한 불우한", 36: "희생된", 37: "억울한", 38: "괴로워하는", 39: "버려진", 40: "당황", 41: "고립된(당황한)", 42: "남의 시선을 의식하는", 43: "외로운", 44: "열등감", 45: "죄책감의", 46: "부끄러운", 47: "혐오스러운", 48: "한심한", 49: "혼란스러운(당황한)", 50: "기쁨", 51: "감사하는", 52: "신뢰하는", 53: "편안한", 54: "만족스러운", 55: "흥분", 56: "느긋", 57: "안도", 58: "신이 난", 59: "자신하는" } kote_emotion_groups = { "신남": [ "기쁨", "흥분", "신이 난", "자신하는", "만족스러운" ], "감탄": [ "감사하는", "놀라운", "감동적인", "경외감", "신기한", "감탄" ], "편안함": [ "편안한", "안도", "느긋", "신뢰하는", "조용한" ], "안정": [ "불안", "두려운", "걱정스러운", "초조한", "취약한", "긴장된" ], "위로": [ "외로운", "고립된", "버려진", "상처", "낙담한", "실망한", "슬픔", "우울한", "눈물이 나는" ], "감정전환": [ "분노", "노여워하는", "악의적인", "툴툴대는", "염세적인", "한심한", "답답한", "짜증내는","스트레스 받는" ], "혼란회복": [ "혼란스러운", "당혹스러운", "고립된(당황한)", "혼란스러운(당황한)", "남의 시선을 의식하는", "부끄러운", "마비된", "죄책감의" ], "해방": [ "성가신", "스트레스 받는", "답답한", "억울한", "탈출하고 싶은" ], "기대감": [ "설레는", "기대되는", "두근거리는", "간절한", "희망적인" ], "부정": [ "구역질 나는", "질투하는", "배신당한", "충격 받은", "혐오스러운", "가난한 불우한", "희생된", "좌절한", "방어적인", "회의적인", "열등감" ] } klue_label_to_group = {} klue_to_general = {} # label → group 매핑 준비 for group, keywords in kote_emotion_groups.items(): for word in keywords: klue_label_to_group[word] = group # klue_emotions에 있는 감정 키들을 그룹화 for klue_id, klue_label in klue_emotions.items(): group = klue_label_to_group.get(klue_label, "부정") klue_to_general[klue_id] = group emotion_override_dict = { # 위로 "기분이 안 좋아": ("우울한", "위로"), "기운이 없어": ("낙담한", "위로"), "힘들어": ("슬픔", "위로"), "지쳤어": ("슬픔", "위로"), "피곤해": ("우울한", "위로"), "외로워": ("외로운", "위로"), "울적해": ("우울한", "위로"), "허전해": ("슬픔", "위로"), "무기력해": ("우울한", "위로"), "마음이 허해": ("낙담한", "위로"), "눈물이 나": ("눈물이 나는", "위로"), "속상해": ("실망한", "위로"), "의욕이 없어": ("우울한", "위로"), "지치고 힘들어": ("슬픔", "위로"), "마음이 아파": ("슬픔", "위로"), # 감정전환 "답답해": ("짜증내는", "감정전환"), "짜증나": ("짜증내는", "감정전환"), "화나": ("분노", "감정전환"), "스트레스 받아": ("스트레스 받는", "감정전환"), "스트레스": ("스트레스 받는", "감정전환"), "짜증": ("짜증내는", "감정전환"), "터질 것 같아": ("분노", "감정전환"), "폭발할 것 같아": ("노여워하는", "감정전환"), "열받아": ("분노", "감정전환"), "화가 치밀어": ("노여워하는", "감정전환"), "머리 아파": ("성가신", "감정전환"), # 편안함 "조용한 곳": ("편안한", "편안함"), "조용한": ("편안한", "편안함"), "한적한 데 가고 싶어": ("편안한", "편안함"), "쉴 곳": ("안도", "편안함"), "마음이 편한 곳": ("편안한", "편안함"), "편안한 여행": ("편안한", "편안함"), "힐링이 필요해": ("편안한", "편안함"), "아무 생각 없이 쉬고 싶어": ("느긋", "편안함"), # 감탄 "감동받고 싶어": ("감사하는", "감탄"), "감동적인": ("감사하는", "감탄"), "감동": ("감사하는", "감탄"), "놀라운 경험": ("감탄", "감탄"), "신기한 거": ("감탄", "감탄"), "인상적인": ("감탄", "감탄"), "경이로운": ("감탄", "감탄"), "감탄할 만한": ("감탄", "감탄"), "와 하고 싶어": ("감탄", "감탄"), # 신남 "설레": ("신이 난", "신남"), "신나": ("신이 난", "신남"), "행복해": ("기쁨", "신남"), "기대돼": ("기쁨", "신남"), "재밌는 거": ("기쁨", "신남"), "기분 좋음": ("기쁨", "신남"), "즐거운 여행": ("기쁨", "신남"), "들뜬다": ("흥분", "신남"), # 혼란회복 "혼란스러워": ("혼란스러운", "혼란회복"), "헷갈려": ("혼란스러운", "혼란회복"), "마음이 복잡해": ("혼란스러운", "혼란회복"), "정리가 안 돼": ("혼란스러운", "혼란회복"), "머리가 복잡해": ("혼란스러운", "혼란회복"), "정신없어": ("혼란스러운", "혼란회복"), # 안정 "안정이 필요해": ("불안", "안정"), "불안해": ("불안", "안정"), "긴장돼": ("초조한", "안정"), "마음이 불편해": ("걱정스러운", "안정"), "좀 진정하고 싶어": ("안도", "안정"), "안정": ("안도", "안정"), # 부정 "살기 싫어": ("염세적인", "부정"), "인생이 재미없어": ("염세적인", "부정"), "세상이 싫다": ("환멸을 느끼는", "부정"), "세상이 싫어": ("환멸을 느끼는", "부정"), "다 귀찮아": ("마비된", "부정"), "그냥 사라지고 싶어": ("버려진", "부정"), "의미가 없어": ("환멸을 느끼는", "부정") } # -------------------- 의도기반 키워드 -------------------- intent_keywords = { "쇼핑" : ["쇼핑", "기념품", "득템", "특산품", "사고 싶어", "쇼핑몰", "현지 물품"], "실내" : ["실내", "비 오는 날 갈만한 데", "실내 장소", "실내 관광", "실내 데이트 코스"], "가족" : ["가족", "가족끼리", "가족과 함께", "아이와 갈만한 데", "가족여행", "부모님이랑 여행", "가족 단위", "아이 동반"], "워터파크" : ["워터파크", "물놀이", "워터 슬라이드 타러", "수영장", "파도풀", "어트랙션"], "자연" : ["자연", "풍경 좋은", "공기 좋은"], "전망" : ["전망", "뷰 좋은", "탁 트인", "경치 좋은", "전망대"], "포토존" : ["포토존", "사진 찍다", "인생샷", "인스타용", "사진 명소", "포토 스팟", "이쁘게 찍히는 곳"], "해양체험" : ["해양 체험", "스노클링", "다이빙", "수상 체험", "해양 액티비티", "해양 스포츠"], "수족관" : ["수족관", "아쿠아리움", "물고기 보러", "해양 생물 보러"], "종교" : ["종교", "웅장한 건축물", "성스러운 분위기", "명상", "종교적", "기도"], "성당" : ["성당", "성지순례", "역사 깊은 성당", "대성당", "가우디"], "예식장" : ["결혼식", "웨딩 촬영", "웨딩", "예식장"], "커플" : ["커플", "연인", "데이트 코스", "여자친구", "남자친구"], "랜드마크" : ["랜드마크", "유명한 장소", "대표 명소", "도시 명소", "시그니처 스팟", "상징적인", "꼭 들러야 하는"], "역사" : ["역사적인", "웅장한 건축물", "옛날 건축물", "역사", "전통 깊은", "유적지"], "궁전" : ["궁전", "웅장한 건축물", "왕이 살던", "고궁"], "문화체험" : ["전통 체험", "문화 체험", "문화적 경험"], "박물관" : ["박물관"], "예술감상" : ["예술 작품", "예술 작품 감상", "창의력 자극"], "체험관" : ["과학관", "실내 체험"], "광장" : ["도시 중심지", "사람 많은", "광장"], "산책" : ["산책", "걷기 편한", "산책로"], "미술관" : ["미술관", "그림 보러", "전시회 데이트", "아트 갤러리"], "공연" : ["오페라", "극장", "연극", "공연"], "유람선" :["유람선", "바다 위", "크루즈", "수상 관광", "배 타고"], "야경" : ["야경", "밤에 가기 좋은", "불빛 예쁜", "야경 명소", "야경 감상", "야경 스팟"], "이동관광" : ["차량 투어", "시티 투어", "이동하면서", "투어"], "공원" : ["공원"], "도심공원" : ["도심공원", "도시와 공원을 함께"], "문화거리" : ["전통 있는 거리", "예술 거리", "감성 골목", "문화 거리", "골목길", "분위기 있는 거리"], "호수" : ["호수", "호수 뷰 좋은", "물가 근처 조용한 데"], "휴양지" : ["휴식", "휴양지", "쉬는 곳", "조용한 휴양지"], "성" : ["성"], "관람차" : ["관람차", "하늘에서"], "테마전시" : ["특정 주제 전시", "체험형 전시", "이색 전시"], "강" : ["강", "강변 뷰"], "경기장" : ["축구장", "경기장", "응원"], "사원" : ["사찰", "사원", "불상"], "시장" : ["시장", "길거리 음식"], "야시장" : ["야시장", "밤에 여는 시장", "밤에 먹거리 많은 데", "포장마차 거리", "밤 분위기 좋은 시장"], "동물원" : ["동물원", "동물"], "기차" : ["기차", "열차", "관광열차"], "항구" : ["항구 도시", "항구", "항구 풍경", "항구 마을"], "겨울스포츠" : ["스키", "스노보드", "겨울 액티비티", "겨울 스포츠", "설경 보면서 스포츠"], "식물원" : ["식물원", "식물 구경"], "케이블카" : ["케이블카"], "해변" : ["바다", "해변", "모래사장", "모래", "파도", "해수욕장"], "테마파크" : ["놀이공원", "테마파크", "놀이터", "어트랙션", "놀이기구"], "트레킹" : ["트레킹", "산 따라 걷기", "자연 속 걷기"], "섬" : ["조용한 섬 마을", "섬"], "미식" : ["미식", "맛있는", "현지 음식", "먹거리 투어", "맛집"], "버스" : ["버스"], "기념관" : ["기념관"], "신사" : ["신사", "토리이"] } intent_to_category = {k: [k] for k in intent_keywords.keys()} category_mapping = {k: k for k in intent_keywords.keys()} emotion_to_category_boost = { "신남": ["테마파크", "해양체험", "미식", "문화거리", "야시장", "워터파크"], "기대감": ["랜드마크","전망", "문화체험", "이동관광", "포토존", "공연"], "편안함": ["자연", "산책", "공원", "해변", "호수", "식물원", "도심공원"], "안정": ["휴양지", "호수", "성당", "미술관", "예술감상"], "감탄": ["랜드마크", "전망", "야경", "예술감상", "역사", "종교", "포토존"], "혼란회복": ["종교", "도심공원", "미술관", "산책", "트레킹"], "감정전환": ["테마파크", "동물원", "수족관", "문화체험", "쇼핑", "미식"], "위로": ["자연", "호수", "동물원", "휴양지", "성당"], "부정": ["자연", "공원", "섬"] } # ---------------------안내문구 매핑 -------------------- theme_ui_map = { "자연/풍경 감상형": ("힐링 여행지 🧘", "자연과 풍경 속에서 편안해지는 조용한 휴식"), "가족/체험 투어형": ("체험 여행지 🎢", "이색적인 활동으로 즐기는 생생한 전환"), "쇼핑/거리 체험형": ("쇼핑 여행지 🛍", "설렘 가득한 거리에서 현지의 색을 담은 즐거움"), "박물관/문화 감상형": ("문화 여행지 🎨", "예술과 전통이 살아있는 공간에서 느끼는 감동"), "랜드마크/종교 건축형": ("명소 여행지 🏛", "감탄을 부르는 풍경과 함께 도시의 상징을 만나다"), } ui_to_theme_map = {v[0]: k for k, v in theme_ui_map.items()} theme_opening_lines = { "힐링 여행지 🧘": "여유와 자연을 느낄 수 있는 ‘힐링 여행지’ {}곳을 추천드릴게요.", "체험 여행지 🎢": "몸과 마음이 들뜨는 ‘체험 여행지’ {}곳을 추천드릴게요.", "문화 여행지 🎨": "예술과 이야기가 있는 ‘문화 여행지’ {}곳을 추천드릴게요.", "쇼핑 여행지 🛍": "현지 먹거리가 가득한 ‘쇼핑 여행지’ {}곳을 추천드릴게요.", "명소 여행지 🏛": "문화와 상징이 깃든 ‘명소 여행지’ {}곳을 추천드릴게요.", } intent_opening_lines = { "쇼핑": "🛍 현지 물품이 가득한 쇼핑하기 좋은 여행지 추천드릴게요.", "실내": "🏠 비 오는 날에도 즐길 수 있는 실내 여행지를 추천드릴게요.", "가족": "👨‍👩‍👧‍👦 가족 모두 함께 즐길 수 있는 따뜻한 여행지를 소개할게요.", "워터파크": "💦 신나게 물놀이할 수 있는 워터파크 명소를 추천드릴게요.", "자연": "🌿 푸르른 풍경과 자연을 느낄 수 있는 여행지를 소개할게요.", "전망": "🔭 한눈에 뷰가 들어오는 전망 좋은 여행지를 추천드릴게요.", "포토존": "📸 인생샷 남기기 좋은 포토 스팟 여행지를 소개할게요.", "해양체험": "🤿 스노클링, 다이빙 등 해양 액티비티 명소를 추천드릴게요.", "수족관": "🐠 해양 생물을 가까이서 만날 수 있는 수족관을 소개할게요.", "종교": "🕍 성스러운 분위기를 느낄 수 있는 종교 명소를 추천드릴게요.", "성당": "⛪ 역사 깊은 아름다운 성당 여행지를 추천드릴게요.", "예식장": "💒 로맨틱한 웨딩 촬영지와 예식장을 소개할게요.", "커플": "💕 데이트하기 좋은 로맨틱한 커플 여행지를 추천드릴게요.", "랜드마크": "📍 도시를 대표하는 상징적인 명소를 소개할게요.", "역사": "📜 과거의 이야기가 담긴 역사적인 장소를 추천드릴게요.", "궁전": "🏰 화려한 궁전과 왕실의 흔적이 담긴 여행지를 소개할게요.", "문화체험": "🧵 전통을 직접 체험할 수 있는 문화 여행지를 추천드릴게요.", "박물관": "🏛 지식을 쌓을 수 있는 박물관 여행지를 추천드릴게요.", "예술감상": "🎨 감성과 창의력이 자극되는 예술 공간을 소개할게요.", "체험관": "🧪 직접 배우고 즐길 수 있는 체험형 공간을 추천드릴게요.", "광장": "🧭 현지 분위기를 느낄 수 있는 활기찬 광장을 소개할게요.", "산책": "🚶 조용히 걷기 좋은 산책 코스를 추천드릴게요.", "미술관": "🖼 전시회와 그림 감상이 가능한 미술관 명소를 소개할게요.", "공연": "🎭 공연과 무대를 즐길 수 있는 극장 여행지를 추천드릴게요.", "유람선": "🚢 바다 위에서 즐기는 유람선 여행지를 소개할게요.", "야경": "🌃 밤하늘과 불빛이 아름다운 야경 명소를 추천드릴게요.", "이동관광": "🚌 차량으로 편하게 이동하며 즐기는 투어 여행지를 소개할게요.", "공원": "🌳 자연을 품은 여유로운 공원을 추천드릴게요.", "도심공원": "🏞 도시 속 쉼표가 되어줄 도심공원을 소개할게요.", "문화거리": "🧱 예술과 전통이 깃든 감성 골목길 여행지를 추천드릴게요.", "호수": "🏞 잔잔한 물결이 있는 힐링 호수 명소를 소개할게요.", "휴양지": "🌴 조용히 쉬어가기 좋은 휴양지 곳을 추천드릴게요.", "성": "🏯 중세 분위기를 느낄 수 있는 고풍스러운 성을 소개할게요.", "관람차": "🎡 하늘 위에서 풍경을 즐길 수 있는 관람차 명소를 추천드릴게요.", "테마전시": "🖼 이색적인 테마 전시로 가득한 공간을 소개할게요.", "강": "🌊 물길 따라 여유를 느낄 수 있는 강변 여행지를 추천드릴게요.", "경기장": "⚽ 스포츠의 열기가 가득한 경기장 여행지를 소개할게요.", "사원": "🛕 고요한 분위기의 전통 사찰 여행지를 추천드릴게요.", "시장": "🧺 현지 분위기가 살아있는 전통 시장을 소개할게요.", "야시장": "🌙 밤이 더 아름다운 야시장 먹거리 여행지를 추천드릴게요.", "동물원": "🦁 귀여운 동물들을 만날 수 있는 동물원 명소를 추천드릴게요.", "기차": "🚂 느릿하게 달리는 기차와 함께하는 여행지를 소개할게요.", "항구": "⚓ 바다와 도시가 만나는 항구 풍경 명소를 추천드릴게요.", "겨울스포츠": "⛷ 스키와 보드로 겨울을 즐길 수 있는 스포츠 여행지를 소개할게요.", "식물원": "🌺 푸르른 식물이 가득한 식물원 힐링 공간을 추천드릴게요.", "케이블카": "🚡 하늘에서 풍경을 감상할 수 있는 케이블카 여행지를 소개할게요.", "해변": "🏖 햇살과 파도가 반기는 해변 명소를 추천드릴게요.", "테마파크": "🎠 어트랙션과 재미가 가득한 테마파크 여행지를 소개할게요.", "트레킹": "🥾 자연 속을 걷는 트레킹 여행지를 추천드릴게요.", "섬": "🏝 바다 위 고요한 섬 여행지를 소개할게요.", "미식": "🍽 현지 음식을 맛볼 수 있는 미식 여행지를 추천드릴게요.", "버스": "🚌 이동이 편리한 버스 투어 여행지를 소개할게요.", "기념관": "🗿 기억을 간직한 기념관 여행지를 추천드릴게요.", "신사": "⛩ 신비롭고 평온한 분위기의 신사 여행지를 소개할게요.", } #--------------------패키지 문구--------------------------- theme_title_phrases = { "힐링 여행지 🧘": [ "완전 휴식 힐링", "조용한 쉼표 감성", "마음 회복 여정", "여유 가득 치유", "재충전 슬로우 라이프", "혼자만의 위로 여행", "스트레스 해소 힐링", "편안한 하루 쉼", "무리 없는 휴식 코스", "고요한 자연 속 여유" ], "체험 여행지 🎢": [ "액티비티 가득 체험", "전통+현지활동 즐기기", "오감만족 현지 체험", "생생한 투어 중심", "로컬 라이프 몰입형", "이색 활동 탐험 여행", "다채로운 체험 나들이", "직접 해보는 체험 위주", "참여형 투어 여행", "이색적인 하루 체험" ], "문화 여행지 🎨": [ "감성 깊은 문화 산책", "예술+역사 감상 투어", "전통과 현대의 조화", "미술과 공연 탐방형", "고요한 박물관 여행", "유산 따라 걷는 길", "명화 따라 가는 여행", "인문학 감성 문화 코스", "예술품과 함께하는 여정", "전시+예술 감상 중심" ], "쇼핑 여행지 🛍": [ "현지 감성 쇼핑", "트렌디 마켓 탐방", "먹거리+기념품 거리투어", "핫플 마켓 나들이", "로컬 브랜드 쇼핑", "시장 골목 체험형 쇼핑", "감성 소품 수집 여행", "실속형 쇼핑 탐방", "득템 투어 여행", "즐거운 거리 탐방" ], "명소 여행지 🏛": [ "랜드마크 집중 투어", "유명 명소 핵심일정", "도시 상징 명소여행", "사진 맛집 스팟 투어", "대표 장소 완전정복", "상징적 장소 따라가기", "유적+건축 핵심 코스", "도시 한눈에 보기 여행", "베스트 명소 몰아보기", "상징 명소 스탬프 투어" ] } feature_phrase_map = { frozenset(["숙소", "일정"]): [ "편안한 숙소와 알찬 일정이 돋보이는 여행이에요", "조용한 숙소에서 시작해 일정까지 여유롭게 즐겨보세요", "숙소와 일정 모두 균형잡힌 완벽한 여행이 기다려요" ], frozenset(["숙소", "가이드"]): [ "친절한 가이드와 편안한 숙소가 여행의 품격을 높여줘요", "믿음직한 가이드와 푹 쉴 수 있는 숙소가 조화를 이뤄요", "좋은 숙소와 세심한 가이드가 잊지 못할 추억을 만들어줘요" ], frozenset(["숙소", "식사"]): [ "맛있는 음식과 포근한 숙소가 하루를 완벽하게 마무리해줘요", "편안한 숙소에서 휴식하고, 입맛 돋우는 식사까지 즐겨보세요", "숙소와 식사 모두 기대 이상! 하루가 즐거워지는 조합이에요" ], frozenset(["숙소", "가성비"]): [ "합리적인 가격에 숙소 퀄리티까지 만족스러운 여행이에요", "가성비 좋고 편한 숙소 덕분에 여유로운 여행이 가능해요", "가성비와 숙소 퀄리티 모두 잡은 최고의 선택이에요" ], frozenset(["숙소", "이동수단"]): [ "숙소와 교통 모두 걱정 없는 편안한 여행 코스예요", "편한 숙소와 편리한 이동으로 피로 없이 즐겨요", "숙소 위치도 좋고 이동도 편해서 스트레스 없는 일정이에요" ], frozenset(["일정", "가이드"]): [ "계획적인 일정과 세심한 가이드가 함께하는 만족도 높은 여행이에요", "친절한 가이드의 안내로 일정이 훨씬 알차고 편안해요", "시간 낭비 없이 똑똑하게 즐기는 일정, 믿음직한 가이드까지 완벽해요" ], frozenset(["일정", "식사"]): [ "시간 알차고 식사까지 만족도 높은 구성이에요", "일정이 짜임새 있고, 식사도 군더더기 없어요", "식사 시간이 기다려질 만큼 구성 좋은 일정이에요" ], frozenset(["일정", "가성비"]): [ "알찬 일정과 가성비 높은 구성으로 만족스러운 여행이에요", "시간도 돈도 아끼는 실속 있는 일정이에요", "지루하지 않은 알찬 일정과 착한 가격, 가성비 최고에요" ], frozenset(["일정", "이동수단"]): [ "효율적인 일정과 편리한 이동으로 스트레스 없는 여행이에요", "이동이 편해서 일정이 더 즐겁고 여유로워요", "부드러운 이동 루트와 알찬 일정의 조화가 인상 깊어요" ], frozenset(["가이드", "식사"]): [ "친절한 가이드와 맛있는 음식이 감동을 더해줘요", "가이드의 설명과 맛있는 음식으로 여행이 풍성해져요", "입도 마음도 만족스러운 식사와 가이드 조합이에요" ], frozenset(["가이드", "가성비"]): [ "세심한 안내와 좋은 구성, 가격까지 잡은 실속 있는 여행이에요", "저렴한 가격에도 훌륭한 가이드를 만날 수 있어요", "가격 대비 서비스 최고! 가이드 덕에 더 알차고 완벽해요" ], frozenset(["가이드", "이동수단"]): [ "믿음직한 가이드와 쾌적한 이동수단으로 편안한 여행이에요", "가이드의 동선 설계가 이동을 훨씬 효율적으로 만들어줘요", "편안한 이동과 노련한 가이드의 조합으로 긴 여정도 든든해요" ], frozenset(["식사", "가성비"]): [ "만족스러운 식사와 가격까지 착한 여행 코스예요", "음식 퀄리티와 가성비까지 잡은 패키지 구성이에요", "식사도 푸짐하고 가격도 합리적인 최고의 구성!" ], frozenset(["식사", "이동수단"]): [ "여유로운 이동과 든든한 식사로 여행이 더욱 즐거워요", "편안한 버스 타고 가는 길마다 맛집 투어 같은 경험이에요", "친절한 이동기사님과 맛있는 식사가 조화로운 패키지에요" ], frozenset(["가성비", "이동수단"]): [ "교통 편의성과 가격 모두 만족하는 실속 여행이에요", "가격 착하고 이동도 편리해서 부담 없이 가기 좋은 패키지에요", "가볍게 떠나기 좋은 가성비+교통 조합이에요" ] } # -------------------- 챗봇 연동 -------------------- def get_intent_intro_message(intent: str) -> str: intent_opening_texts = { "쇼핑":"특별한 기념품과 현지의 매력을 느끼고 싶으시군요.", "실내": "날씨와 상관없이 알차게 여행을 즐기고 싶으시군요.", "가족": "가족과 함께 소중한 추억을 만들고 싶으시군요.", "워터파크": "물놀이로 스트레스를 날리고 싶으신가요?", "자연": "자연 속에서 힐링하고 싶은 마음이 느껴져요.", "전망": "탁 트인 풍경을 바라보며 여유를 느끼고 싶으시군요.", "포토존": "잊지 못할 순간을 사진으로 남기고 싶으시군요.", "해양체험": "바다 속 세상을 가까이에서 느끼고 싶으신가요?", "수족관": "해양 생물을 직접 보고 싶으신가요?", "종교": "마음의 평화를 찾고 싶은 여행을 원하시나요?", "성당": "아름다운 건축과 고요함을 느끼고 싶으시군요.", "예식장": "특별한 순간을 로맨틱하게 남기고 싶으시군요.", "커플": "둘만의 로맨틱한 시간을 보내고 싶으시군요.", "랜드마크": "도시의 대표 명소에서 그 지역의 매력을 느끼고 싶으시군요.", "역사": "과거의 흔적 속에서 깊은 이야기를 느끼고 싶으신가요?", "궁전": "왕실의 화려함을 경험하고 싶으시군요.", "문화체험": "전통 문화를 직접 체험하고 싶으신가요?", "박물관": "새로운 지식과 흥미로운 전시를 경험하고 싶으시군요.", "예술감상": "감성과 창의력을 자극하고 싶으시군요.", "체험관": "직접 해보는 체험으로 생생한 여행을 원하시나요?", "광장": "현지 분위기를 직접 느끼고 싶으시군요.", "산책": "여유롭게 걸으며 생각 정리하고 싶으시군요.", "미술관": "예술작품 속에서 여유와 영감을 느끼고 싶으시군요", "공연": "생생한 무대와 감동을 직접 느끼고 싶으시군요.", "유람선": "바다 위에서 낭만적인 시간을 보내고 싶으시군요.", "야경": "낮보다 아름다운 밤 풍경을 감상하고 싶으시군요.", "이동관광": "편하게 이동하면서 다양한 명소를 보고 싶으시군요.", "공원": "잠시 일상을 벗어나 여유를 느끼고 싶으시군요.", "도심공원": "도시 속에서 잠깐의 쉼을 원하시나요?", "문화거리": "걷기만 해도 감성이 채워지는 골목을 원하시나요?", "호수": "잔잔한 풍경 속에서 마음의 평화를 찾고 싶으시군요.", "휴양지": "아무 생각 없이 쉬어가고 싶은 순간이시군요.", "성": "중세 감성의 고풍스러움을 느끼고 싶으시군요.", "관람차": "높은 곳에서 색다른 풍경을 보고 싶으시군요.", "테마전시": "독특한 콘텐츠로 새로운 자극을 원하시나요?", "강": "물소리를 들으며 여유를 느끼고 싶으시군요.", "경기장": "현장의 열기와 박진감을 느끼고 싶으시군요.", "사원": "고요한 공간에서 마음을 가라앉히고 싶으시군요.", "시장": "현지의 진짜 일상을 경험하고 싶으시군요.", "야시장": "밤이 더 매력적인 여행을 기대하고 계시군요.", "동물원": "귀여운 친구들을 만나며 힐링하고 싶으시군요.", "기차": "천천히 이동하며 풍경을 즐기고 싶으시군요.", "항구": "바닷바람과 함께 낭만을 느끼고 싶으시군요.", "겨울스포츠": "눈 위에서 짜릿한 활동을 원하시나요?", "식물원": "피톤치드향이 가득한 공간에서 편안함을 느끼고 싶으시군요.", "케이블카": "색다른 시야로 풍경을 바라보고 싶으시군요.", "해변": "햇살과 바다를 함께 즐기고 싶으시군요.", "테마파크": "하루종일 웃고 뛰어놀고 싶은 기분이신가요?", "트레킹": "자연 속에서 몸과 마음을 걷고 싶으시군요.", "섬": "복잡함에서 벗어나 고요함을 찾고 싶으시군요.", "미식": "새로운 맛으로 여행의 즐거움을 더하고 싶으시군요.", "버스": "편하게 둘러보며 여행하고 싶으시군요.", "기념관": "그 시절을 다시 느끼고 싶으신가요?", "신사": "신비롭고 조용한 장소를 찾고 계시군요.", } if intent in intent_opening_texts: return intent_opening_texts[intent] else: raise ValueError(f"의도 '{intent}'에 맞는 문구가 정의되어 있지 않습니다.") def determine_weather_description_official(row): try: rain = float(row["강수량"]) humidity = float(row["습도"]) except Exception: return "날씨 정보 없음" if rain >= 10: return "비가 많이 오는 날씨예요." elif rain >= 3: return "비가 오는 날씨예요." elif rain >= 0.5: return "약한 비가 오는 날씨예요." else: if humidity >= 85: return "흐린 날씨예요." elif humidity >= 65: return "구름이 많은 날씨예요." else: return "맑은 날씨예요." def get_weather_message(city, weather_df, date="2025-06-01"): date = pd.to_datetime(date).date() # '날짜' 컬럼이 datetime으로 되어있으면 date로 변환 if pd.api.types.is_datetime64_any_dtype(weather_df["날짜"]): weather_df["날짜_일자"] = weather_df["날짜"].dt.date else: weather_df["날짜_일자"] = pd.to_datetime(weather_df["날짜"], errors="coerce").dt.date # 2. 정확 일치 시도 exact_match = weather_df[ (weather_df["여행도시"].str.strip() == city.strip()) & (weather_df["날짜_일자"] == date) ] if not exact_match.empty: row = exact_match.iloc[0] else: # 3. 포함 검색 시도 partial_match = weather_df[ (weather_df["여행도시"].str.contains(city, na=False)) & (weather_df["날짜_일자"] == date) ] if not partial_match.empty: row = partial_match.iloc[0] else: return f"📅 {city}의 {date} 날씨 정보가 없습니다." # 최고 기온 try: temp = f"{float(row['최고_기온']):.1f}" temp_a = f"{float(row['최저_기온']):.1f}°C" except Exception: temp = "정보 없음" # 설명 desc = determine_weather_description_official(row) return f"📅 {row['여행도시']}의 날씨는 {temp}/{temp_a}, {desc}" def generate_intro_message(intent=None, emotion_groups=None, emotion_scores=None, min_emotion_score=15.0): from collections import defaultdict import random emotion_priority = [ "감정전환", "위로", "혼란회복", "안정", "편안함", "감탄", "신남", "부정" ] emotion_messages = { "신남": "🎉 즐거움이 가득한 여행을 찾고 계시는군요.", "편안함": "😌 고요하고 편안한 여행이 필요하시군요.", "안정": "🕊️ 마음의 안정을 찾고 싶으신가 봐요.", "감탄": "😍 감동과 놀라움을 느끼고 싶으시군요.", "혼란회복": "🌀 마음을 정리할 시간이 필요하신가 봐요.", "감정전환": "🔄 기분 전환이 필요한 순간이네요.", "위로": "🤍 지친 마음에 작은 위로가 필요하시군요.", "부정": "😮 잠시 멈춰 숨 돌릴 시간이 필요하시군요." } neutral_messages = [ "지금 이 순간, 어떤 여행이 어울릴지 함께 고민해봤어요.", "기분 전환이 필요하신 것 같아요. 여러 스타일의 여행지를 추천드릴게요.", "딱 떨어지는 목적은 없지만, 어딘가 떠나고 싶을 때가 있죠.", "지금 마음에 맞을 수 있는 여행 스타일 몇 가지를 골라봤어요.", "다양한 감정을 담을 수 있는 여행지를 준비했어요.", "지금의 기분에 맞춰, 어울릴 만한 여행 스타일을 제안드릴게요." ] # 👉 사전 오버라이드 결과 우선 적용 if emotion_groups: # 우선순위에 따라 가장 먼저 매칭되는 감정 그룹 메시지를 리턴 for emo in emotion_priority: if emo in emotion_groups: return emotion_messages.get(emo, random.choice(neutral_messages)) # 👉 모델 감정 점수 기반 적용 group_scores = defaultdict(float) if emotion_scores: for klue_label, score in emotion_scores: group = klue_label_to_group.get(klue_label) if group: group_scores[group] = max(group_scores[group], score) for emo in emotion_priority: if emo in group_scores: return emotion_messages.get(emo, random.choice(neutral_messages)) # 👉 아무것도 없으면 중립 메시지 return random.choice(neutral_messages) def generate_region_intro(city=None, country=None):# 추가 함############################ name = city if city else country templates = [ f"✨ 낭만이 가득한 {name}의 매력적인 여행지로 여러분을 초대할게요!", f"🌏 {name}에서만 느낄 수 있는 특별한 감성과 순간을 함께 찾아볼까요?", f"📍 기억에 오래 남을 {name}의 아름다운 여행지들을 하나하나 소개해드릴게요.", f"🌿 {name}에서만 만날수 있는 매력 가득한 여행지를 엄선했어요.", f"🎒 일상 속 쉼표가 필요한 지금, {name}에서 설렘 가득한 여정을 떠나보세요." ] return random.choice(templates) def parse_companion_and_age(text): companions = None age_group = None # 사용자 입력 ➜ 실제 컬럼명 매핑 companion_map = { "혼자": "나혼자", "나혼자": "나혼자", "친구": "친구들과", "친구들": "친구들과", "커플": "커플", "연인": "커플", "가족": "가족여행", "단체": "단체여행" } # 나이대 매핑 (CSV 컬럼 그대로) age_map = { "20대": "20대", "30대": "30대", "40대": "40대", "50대": "50대", "60대": "60대 이상 ", "60대 이상": "60대 이상 " } text = text.strip() # 동행 파싱 for k, mapped in companion_map.items(): if k in text: companions = mapped break # 나이대 파싱 for k, mapped in age_map.items(): if k in text: age_group = mapped break # 숫자만 입력했을 때 처리 if age_group is None: if "20" in text: age_group = "20대" elif "30" in text: age_group = "30대" elif "40" in text: age_group = "40대" elif "50" in text: age_group = "50대" elif "60" in text: age_group = "60대 이상 " return companions, age_group #------------------------- 수정 def make_companion_age_message(companions, age_group): companion_friendly = { "혼자": "혼자", "나혼자": "혼자", "친구": "친구분들과", "친구들과": "친구분들과", "커플": "연인과", "가족여행": "가족분들과", "가족": "가족분들과", "단체여행": "단체로", "단체": "단체로" } age_friendly = { "20대": "20대", "30대": "30대", "40대": "40대", "50대": "50대", "60대 이상": "60대 이상", "60대": "60대 이상" } # ✔ 리스트 → 첫 항목(대표값) 또는 None def to_friendly(terms, mapping): if not terms: return [] if not isinstance(terms, list): terms = [terms] return [mapping[t] for t in terms if t in mapping] friendly_ages = to_friendly(age_group, age_friendly) friendly_companions = to_friendly(companions, companion_friendly) age_text = ", ".join(friendly_ages) + " 여행객" if friendly_ages else "" companion_text = ", ".join(friendly_companions) if friendly_ages and friendly_companions: return f"💡{age_text} {companion_text} 여행하시는 분들께 특히 인기 있는 패키지예요." elif friendly_ages: return f"💡{age_text} 분들께 인기 있는 패키지예요." elif friendly_companions: return f"💡{companion_text} 여행하시는 분들께 특히 인기 있는 패키지예요." else: return "" #------------------------- 같은 그룹에 2개이상 선택이 가능하도록 로직 수정 def filter_packages_by_companion_age(package_df, companions=None, age_group=None, city=None, top_n=5): # 사용자 입력 ➜ 실제 컬럼명 매핑 companion_map = { "혼자": "나혼자", "나혼자": "나혼자", "친구": "친구들과", "친구들": "친구들과", "커플": "커플", "연인": "커플", "가족": "가족여행", "단체": "단체여행" } # 나이대 매핑 (CSV 컬럼 그대로) age_map = { "20대": "20대", "30대": "30대", "40대": "40대", "50대": "50대", "60대": "60대 이상 ", "60대 이상": "60대 이상" } # companions, age_group → 리스트로 통일 comp_list = companions if isinstance(companions, list) else ([companions] if companions else []) age_list = age_group if isinstance(age_group, list) else ([age_group] if age_group else []) companions = [companion_map.get(c) for c in comp_list if companion_map.get(c) in package_df.columns] age_group = [age_map.get(a) for a in age_list if age_map.get(a) in package_df.columns] df = package_df.copy() # city 컬럼 있으면 필터 if city and "여행도시" in df.columns: df = df[df["여행도시"].str.contains(city, na=False)] # 조건1: 동행+연령 if companions and age_group: mask = (df[companions].sum(axis=1) > 0) & (df[age_group].sum(axis=1) > 0) both = df[mask].copy() if len(both) >= top_n: both["점수합"] = both[companions + age_group].sum(axis=1) return both.sort_values("점수합", ascending=False).head(top_n) # 조건2: 연령만 if age_group: age_only = df[df[age_group].sum(axis=1) > 0].copy() if len(age_only) >= top_n: age_only["점수"] = age_only[age_group].sum(axis=1) return age_only.sort_values("점수", ascending=False).head(top_n) # 조건3: 동행만 if companions: comp_only = df[df[companions].sum(axis=1) > 0].copy() if len(comp_only) >= top_n: comp_only["점수"] = comp_only[companions].sum(axis=1) return comp_only.sort_values("점수", ascending=False).head(top_n) # 조건4: 아무 조건도 없거나 개수 부족 return df.sample(n=min(top_n, len(df))) # -------------------- 핵심 함수 -------------------- def get_highlight_message(selected_place, travel_df, external_score_df, festival_df): import random from datetime import datetime # 메시지 풀 messages_pool = { "festival_score": [ "🎉 지금 {city}에서는 '{festival_name}'이 진행 중이에요!", "🎊 '{festival_name}' 축제가 열리고 있어요! 놓치지 마세요." ], "cost_score": [ "💸 여행 비용이 저렴한 편이라 부담 없이 다녀올 수 있어요.", "💰 예상 경비가 낮아서 가성비 좋은 여행이 가능합니다." ], "norm_fx": [ "💱 환율이 떨어져서 환전하기 좋은 시기예요.", "💵 환율이 안정적이라 여행 경비가 절약됩니다.", "1,000원으로 더 많은 금액을 환전할 수 있어요!" ], "norm_cpi": [ "🛍 현지 물가가 저렴해서 여행 경비가 합리적이에요.", "☕ 카페, 식사, 쇼핑까지 부담이 덜해요.", "평균보다 낮은 물가 덕분에 여유로운 여행이 가능해요." ], "트렌드급상승": [ "📈 최근 검색량이 급등했어요. 요즘 뜨는 여행지예요!", "🔥 지금 많은 사람들이 이곳을 검색하고 있어요!" ], "trend_score": [ "⭐ 여행객들에게 꾸준히 사랑받고 있는 곳이에요.", "✨ 언제 가도 만족도가 높은 인기 여행지예요.", "💖 지금도 많은 사람들이 찾는 베스트셀러 여행지예요." ] } # 🎯 축제명 가져오기 함수 def get_festival_name(city, festival_df): today = datetime.today().date() matches = festival_df[festival_df["여행도시"] == city] if matches.empty: return "현지 축제" matches = matches.copy() try: matches["시작일"] = pd.to_datetime(matches["시작일"], errors="coerce").dt.date matches["종료일"] = pd.to_datetime(matches["종료일"], errors="coerce").dt.date except Exception: return "현지 축제" # 진행중 ongoing = matches[(matches["시작일"] <= today) & (matches["종료일"] >= today)] if not ongoing.empty: return random.choice(ongoing["축제명"].dropna().tolist()) # 다가오는 upcoming = matches[matches["시작일"] > today].sort_values("시작일") if not upcoming.empty: return upcoming.iloc[0]["축제명"] return "현지 축제" # 🌍 여행지에 해당하는 도시/나라 가져오기 place_row = travel_df[travel_df["여행지"] == selected_place] if place_row.empty: return None place_row = place_row.iloc[0] city = place_row["여행도시"] country = place_row["여행나라"] # 외부요인 데이터에서 해당 도시/나라 찾기 external_row = external_score_df[ (external_score_df["여행도시"] == city) & (external_score_df["여행나라"] == country) ] if external_row.empty: return None external_row = external_row.iloc[0] # 조건별 만족 여부 체크 highlight_candidates = [] if external_row.get("festival_score", 0) >= 2: festival_name = get_festival_name(city, festival_df) if festival_name != '현지 축제': msg = random.choice(messages_pool["festival_score"]).format(city=city, festival_name=festival_name) highlight_candidates.append(msg) if external_row.get("cost_score", 0) == 10: highlight_candidates.append(random.choice(messages_pool["cost_score"])) if external_row.get("norm_fx", 99) < 1.0: highlight_candidates.append(random.choice(messages_pool["norm_fx"])) if external_row.get("norm_cpi", 99) < 1.0: highlight_candidates.append(random.choice(messages_pool["norm_cpi"])) if str(external_row.get("트렌드급상승", "")).strip() == "급상승": highlight_candidates.append(random.choice(messages_pool["트렌드급상승"])) if external_row.get("trend_score", 0) >= 6.0: highlight_candidates.append(random.choice(messages_pool["trend_score"])) if not highlight_candidates: fallback_messages = [ "🌿 일상을 벗어나 새로운 경험을 만들어주는, {city}로 떠나보세요.", "🎈 뚜렷한 목적 없이도 좋은 기억만 남게 해주는, {city}예요.", "🌸 매력이 흘러넘치는 도시, {city}에서 행복한 시간을 보내보세요." ] return random.choice(fallback_messages).format(city=city) # 랜덤 1개만 선택 return random.choice(highlight_candidates) def apply_weighted_score_random_top(df, top_n=50, sample_k=3): # 🛡 원본 백업: 외부 점수 없는 것도 포함된 전체 df original_df = df.drop_duplicates(subset=["여행지"]).copy() # 외부 점수와 병합 merged = pd.merge(df, external_score_df, on=["여행도시", "여행나라"], how="left") merged["종합점수"] = merged["종합점수"].fillna(0) # 상위 점수 정렬 ranked = merged.sort_values(by="종합점수", ascending=False).drop_duplicates(subset=["여행지"]) # 최상위 top_n 중 sample_k개 선택 top_df = ranked.head(top_n) top_count = min(sample_k, len(top_df)) sampled_top = top_df.sample(n=top_count, random_state=random.randint(1, 9999)) # 부족하면 bottom에서 채우기 bottom_pool = ranked.iloc[top_n:] needed = max(0, 3 - len(sampled_top)) if needed > 0 and not bottom_pool.empty: sampled_bottom = bottom_pool.sample(n=min(needed, len(bottom_pool)), random_state=random.randint(1, 9999)) final_df = pd.concat([sampled_top, sampled_bottom], ignore_index=True) else: final_df = sampled_top # 🔒 최종 보완: 외부 점수 없던 원본 df에서 무조건 3개 채우기 if len(final_df) < 3: additional = original_df[~original_df["여행지"].isin(final_df["여행지"])] if not additional.empty: fill_df = additional.sample(n=min(3 - len(final_df), len(additional)), random_state=random.randint(1, 9999)) final_df = pd.concat([final_df, fill_df], ignore_index=True) return final_df def apply_weighted_score_filter(df, top_n=50, sample_k=3): return apply_weighted_score_random_top(df, top_n=top_n, sample_k=sample_k) def override_emotion_if_needed(text): for keyword, (emotion_label, emotion_group) in emotion_override_dict.items(): if keyword in text: return [(emotion_label, 50.0)], [emotion_group] return None def analyze_emotion(text: str): model = load_sentiment_model() tokenizer = load_tokenizer() override = override_emotion_if_needed(text) if override: return override inputs = tokenizer(text, return_tensors="pt", truncation=True) with torch.no_grad(): logits = model(**inputs).logits # ← model은 '단일 객체' probs = F.softmax(logits, dim=1)[0] top_indices = torch.topk(probs, k=5).indices.tolist() top_emotions = [(klue_emotions[i], float(probs[i]) * 100) for i in top_indices] top_emotion_groups = list(dict.fromkeys( [klue_to_general[i] for i in top_indices if probs[i] > 0.05] )) return top_emotions, top_emotion_groups def detect_intent(user_input): force_map = { "수족관": "수족관", "아쿠아리움":"수족관", "워터파크": "워터파크", "쇼핑":"쇼핑", "커플":"커플", "실내":"실내", "가족":"가족", "산책":"산책", "전망":"전망", "해양 체험":"해양체험", "종교":"종교", "성당":"성당", "웨딩":"예식장", "역사":"역사", "자연":"자연", '부모님':'가족', '애들':'가족','아이들':'가족', "궁전":"궁전", "문화 체험": "문화체험", "박물관":"박물관", "예술 작품":"예술감상", "과학관":"체험관", "광장":"광장", "미술관":"미술관", "공연":"공연", "유람선":"유람선", "야경":"야경", "호수":"호수", "휴양지":"휴양지","관람차":"관람차", "강":"강", "경기장":"경기장", "사원":"사원", "시장":"시장", "야시장":"야시장", "동물원":"동물원", "기차":"기차", "항구":"항구", "겨울 스포츠":"겨울스포츠", "식물원":"식물원", "케이블카":"케이블카", "해변":"해변", "바다":"해변", "테마파크":"테마파크", "트레킹":"트레킹", "섬":"섬", "맛있는":"미식", "버스":"버스", "기념관":"기념관", "신사":"신사", '바다':'해변', '인생샷':'포토존','먹방':'미식','소품':'쇼핑','바닷가':'해변','서핑':'해양체험', '일몰':'전망','로맨틱':'커플','브랜드샵':'쇼핑','아울렛':'쇼핑','비치':'해변','고성':'성','고궁':'궁전','문화거리':'문화거리','전통마을':'문화체험', '곤돌라':'케이블카','스카이라인':'전망','힐링':'휴양지', '미식':'쇼핑', '문화':'문화체험', '걷기':'산책', '여자친구':'커플', '남자친구':'커플','연인':'커플' } for keyword, mapped_intent in force_map.items(): if keyword in user_input: return mapped_intent, 1.0 phrases, labels = [], [] for intent, keywords in intent_keywords.items(): for word in keywords: phrases.append(word) labels.append(intent) sbert_model = load_sbert_model() input_emb = sbert_model.encode(user_input, convert_to_tensor=True) phrase_embs = sbert_model.encode(phrases, convert_to_tensor=True) sims = util.cos_sim(input_emb, phrase_embs)[0] max_idx = torch.argmax(sims).item() return labels[max_idx], float(sims[max_idx]) def extract_themes(emotion_groups, intent, force_mode=False): scores = defaultdict(float) if force_mode: mapped = category_mapping.get(intent) if mapped: scores[mapped] += 1.0 return list(scores.keys())[:3] for group in emotion_groups: for cat in emotion_to_category_boost.get(group, []): mapped = category_mapping.get(cat) if mapped: scores[mapped] += 1.0 mapped = category_mapping.get(intent) if mapped: scores[mapped] += 1.5 ranked = sorted(scores.items(), key=lambda x: -x[1]) return [x[0] for x in ranked[:3]] def recommend_places_by_theme(theme, country_filter=None, city_filter=None): today = datetime.today().date() # 1. 테마 필터링 df = travel_df[travel_df['의도테마명'].str.contains(theme, na=False)].drop_duplicates(subset=["여행지"]) # 2. 국가/도시 필터링 if city_filter: df = df[df["여행도시"].str.contains(city_filter)] if country_filter: df = df[df["여행나라"].str.contains(country_filter)] # 3. 비어있으면 빈 DF 리턴 if df.empty: df = pd.DataFrame(columns=travel_df.columns) # 빈 DF라도 컬럼 포함 # ✅ 최소 3개 수집 보장 collected = df.copy() extra_fill = travel_df[ (travel_df['의도테마명'].str.contains(theme, na=False)) & (~travel_df['여행지'].isin(collected['여행지'])) ].drop_duplicates(subset=["여행지"]) needed = 3 - len(collected) if needed > 0 and not extra_fill.empty: fill = extra_fill.sample(n=min(needed, len(extra_fill)), random_state=random.randint(1, 9999)) collected = pd.concat([collected, fill], ignore_index=True) # 여전히 부족하면 무작위로 travel_df에서 채우기 if len(collected) < 3: fallback = travel_df[~travel_df['여행지'].isin(collected['여행지'])].drop_duplicates(subset=["여행지"]) if not fallback.empty: fill = fallback.sample(n=min(3 - len(collected), len(fallback)), random_state=random.randint(1, 9999)) collected = pd.concat([collected, fill], ignore_index=True) # ✅ 필수 컬럼 보장 for col in ["여행도시", "여행나라"]: if col not in collected.columns: collected[col] = None # ✅ 통합테마명 보장 if "통합테마명" not in collected.columns: collected["통합테마명"] = theme else: collected["통합테마명"] = collected["통합테마명"].fillna(theme) return collected def get_festival_info(city): match = festival_df[festival_df['여행도시'] == city] if match.empty: return "없음", None, None row = match.iloc[0] try: start = pd.to_datetime(row["시작일"]).date() end = pd.to_datetime(row["종료일"]).date() if end < today: return "없음", None, None return row["축제명"], start, end except: return "없음", None, None df[["추천축제", "축제시작", "축제종료"]] = df["여행도시"].apply(lambda x: pd.Series(get_festival_info(x))) return df def make_top2_description_custom(row, used_phrases=set()): scores = { k: row.get(k, 0) for k in ["숙소", "일정", "가이드", "식사", "가성비", "이동수단"] } top2 = sorted(scores.items(), key=lambda x: x[1], reverse=True)[:2] top2_keys = frozenset([k for k, _ in top2]) phrases = feature_phrase_map.get(top2_keys, []) if not phrases: return "", used_phrases available = [p for p in phrases if p not in used_phrases] phrase = random.choice(available) if available else random.choice(phrases) used_phrases.add(phrase) return phrase, used_phrases def format_summary_tags_custom(summary): if pd.isna(summary): return "" parts = [s.strip() for s in summary.split(",") if s.strip()] tags = [] i = 0 while i < len(parts): part = parts[i] # 가이드 경비 블록 처리 if "가이드 경비" in part or "가이드경비" in part: guide_block = [part] j = i + 1 while j < len(parts): next_part = parts[j] guide_block.append(next_part) if "선택관광" in next_part: break j += 1 if len(guide_block) > 1: merged = "".join(guide_block[:-1]).replace(" ", "") tags.append(f"#{merged}") tags.append(f"#{guide_block[-1].strip()}") else: tags.extend(f"#{x.strip()}" for x in guide_block) i = j + 1 continue # 일반 항목 tags.append(f"#{part}") i += 1 return " ".join(tags) def recommend_packages( selected_theme, selected_place, travel_df, package_df, theme_ui_map, chat_container=None ): import random # ✅ 통합 테마명 추출 if selected_theme in theme_ui_map: integrated_theme = selected_theme else: integrated_theme = ( travel_df[ travel_df["의도테마명"].str.contains(selected_theme, na=False) ] .drop_duplicates(subset=["여행지"])["통합테마명"] .mode() .iloc[0] ) # ✅ UI 이름 및 도시명 selected_ui_name = theme_ui_map[integrated_theme][0] selected_city = travel_df.loc[ travel_df["여행지"] == selected_place, "여행도시" ].values[0] # ✅ 도시 필터 filtered_package = package_df[ package_df["여행도시"].str.contains(selected_city, na=False) ].copy() # 📝 감성 문구 def make_top2_description(row, used_phrases=set()): scores = { k: row.get(k, 0) for k in ["숙소", "일정", "가이드", "식사", "가성비", "이동수단"] } top2 = sorted(scores.items(), key=lambda x: x[1], reverse=True)[:2] top2_keys = frozenset([k for k, _ in top2]) phrases = feature_phrase_map.get(top2_keys, []) if not phrases: return "", used_phrases available = [p for p in phrases if p not in used_phrases] phrase = random.choice(available) if available else random.choice(phrases) used_phrases.add(phrase) return phrase, used_phrases # 📝 요약정보를 해시태그로 변환 def format_summary_tags(summary): if pd.isna(summary): return "" parts = [s.strip() for s in summary.split(",") if s.strip()] tags = [] i = 0 while i < len(parts): part = parts[i] # 가이드 경비 블록 처리 if "가이드 경비" in part or "가이드경비" in part: guide_block = [part] j = i + 1 while j < len(parts): next_part = parts[j] guide_block.append(next_part) if "선택관광" in next_part: break j += 1 if len(guide_block) > 1: merged = "".join(guide_block[:-1]).replace(" ", "") tags.append(f"#{merged}") tags.append(f"#{guide_block[-1].strip()}") else: tags.extend(f"#{x.strip()}" for x in guide_block) i = j + 1 continue # 일반 항목 tags.append(f"#{part}") i += 1 return " ".join(tags) # ✅ 샘플링 recommend_package = filtered_package.sample( n=min(2, len(filtered_package)), random_state=42 ) # ✅ 문구 생성 recommend_texts = [] title_candidates = theme_title_phrases.get(selected_ui_name, ["추천"]) sampled_titles = random.sample(title_candidates, k=min(2, len(title_candidates))) used_phrases = set() for idx, (_, row) in enumerate(recommend_package.iterrows(), 1): desc, used_phrases = make_top2_description(row.to_dict(), used_phrases) tags = format_summary_tags(row["요약정보"]) title_phrase = sampled_titles[idx - 1] if idx <= len(sampled_titles) else random.choice(title_candidates) title = f"{selected_city} {title_phrase} 패키지" recommend_texts.append( f"""{idx}. {title}
🅼 {desc}
{tags}
\ 💚 바로가기 ↗ """ ) # ✅ 출력 if recommend_texts: full_message = "🧳 이런 패키지를 추천드려요:

" + "

".join(recommend_texts) log_and_render( full_message, sender="bot", chat_container = chat_container, key="recommend_package_intro", ) else: log_and_render( "⚠️ 추천 가능한 패키지가 없습니다.", sender="bot", chat_container = chat_container, key="no_package_warning" ) return def handle_selected_place(selected_place, travel_df, external_score_df, festival_df, weather_df, selected_theme=None, chat_container=None): selected_row = travel_df[travel_df["여행지"] == selected_place].iloc[0] country = selected_row["여행나라"] city = selected_row["여행도시"] message_lines = [] message_lines.append(f"{selected_place}은(는) {city}에 위치해 있어요.") message_lines.append(get_weather_message(city, weather_df)) highlight = get_highlight_message(selected_place, travel_df, external_score_df, festival_df) if highlight: message_lines.append(highlight+"
") ##수정## other = travel_df[(travel_df["여행도시"] == city) & (travel_df["여행지"] != selected_place)].drop_duplicates("여행지") if not other.empty: other_sample = other.sample(n=min(3, len(other)), random_state=42) sample_names = ", ".join(other_sample["여행지"].tolist()) message_lines.append(f"함께 가보면 좋은 여행지: {sample_names}") else: message_lines.append("⚠️ 함께 가볼 다른 여행지가 없어요.") # integrated_theme 추론 추가 if selected_theme is None: theme_row = travel_df[travel_df["여행지"] == selected_place] if not theme_row.empty and pd.notna(theme_row.iloc[0]["통합테마명"]): selected_theme = theme_row.iloc[0]["통합테마명"] full_message = "
".join(message_lines) log_and_render( full_message, sender="bot", key=f"region_detail_{selected_place}", chat_container=chat_container ) recommend_packages( selected_theme=selected_theme, selected_place=selected_place, travel_df=travel_df, package_df=package_df, theme_ui_map=theme_ui_map, chat_container=chat_container ) def main(): user_input = input("요즘, 어떤 여행이 떠오르시나요?") # 1. 감정 및 의도 분석 top_emotions, emotion_groups = analyze_emotion(user_input) intent, intent_score = detect_intent(user_input) country_filter, city_filter = detect_location_filter(user_input) if country_filter or city_filter: loc_str = f"{country_filter or ''} {city_filter or ''}".strip() print(f"🔎 '{city_filter or country_filter}'에 해당하는 여행지들만 기반으로 추천드릴게요!") candidate_themes = extract_themes(emotion_groups, intent, force_mode=(intent_score >= 0.7)) # 2. 출력 print("\n[감정 분석 결과]") for emo, score in top_emotions: print(f"- {emo}: {score:.2f}%") print(f"\n[의도 판단 결과] → {intent} (유사도: {intent_score:.2f})") # 3. 조건 분기 # ✅ case 1: intent 기반 추천 if intent_score >= 0.70: selected_theme = intent print(f"\n[명확한 의도에 따라 자동 추천 테마 선택됨] → {selected_theme}") # 의도 오프닝 문구 출력 ui_name = theme_ui_map.get(selected_theme, (selected_theme,))[0] opening_line = ( theme_opening_lines.get(ui_name) or intent_opening_lines.get(selected_theme) or None ) if opening_line: print(f"\n{opening_line}") theme_df = recommend_places_by_theme(selected_theme, country_filter, city_filter) theme_df = theme_df.drop_duplicates(subset=["여행지"]) result_df = apply_weighted_score_filter(theme_df) if len(result_df) < 3: fallback = travel_df[~travel_df['여행지'].isin(result_df['여행지'])].drop_duplicates(subset=["여행지"]) if not fallback.empty: fill = fallback.sample(n=min(3 - len(result_df), len(fallback)), random_state=random.randint(1, 9999)) result_df = pd.concat([result_df, fill], ignore_index=True) # ✅ case 2: 후보 테마가 1개 elif len(candidate_themes) == 1: selected_theme = candidate_themes[0] print(f"\n추천 가능한 테마가 1개이므로 자동 선택: {selected_theme}") theme_df = recommend_places_by_theme(selected_theme, country_filter, city_filter) theme_df = theme_df.drop_duplicates(subset=["여행지"]) result_df = apply_weighted_score_filter(theme_df) if len(result_df) < 3: fallback = travel_df[~travel_df['여행지'].isin(result_df['여행지'])].drop_duplicates(subset=["여행지"]) if not fallback.empty: fill = fallback.sample(n=min(3 - len(result_df), len(fallback)), random_state=random.randint(1, 9999)) result_df = pd.concat([result_df, fill], ignore_index=True) # ✅ case 3: 복수 테마 → 사용자 선택 else: # 복수 테마의 여행지 전체 수집 all_theme_df = pd.concat([ recommend_places_by_theme(t, country_filter, city_filter) for t in candidate_themes ]) all_theme_df = all_theme_df.drop_duplicates(subset=["여행지"]) # 통합테마명 목록 추출 # 5. 최종 병합 filtered = pd.merge( all_theme_df, external_score_df[["여행나라", "여행도시"]], on=["여행나라", "여행도시"], how="inner" ).drop_duplicates(subset=["여행지"]) # 2) 중복 제거 후 최종 테마 목록 filtered = filtered.drop_duplicates(subset=['여행지']) available_themes = filtered['통합테마명'].dropna().unique().tolist()[:3] ##[:3] 가가 # 💡 감성 UI 포맷으로 출력 print("\n추천 가능한 여행 테마:") for idx, theme in enumerate(available_themes, 1): ui_name, ui_desc = theme_ui_map.get(theme, (theme, "")) print(f"{idx}. {ui_name} – {ui_desc}") print("\n👉 어떤 테마가 끌리시나요?") print(" ".join(f"[{theme_ui_map.get(t, (t,))[0]}]" for t in available_themes)) # 자동 선택 or 사용자 입력 if len(available_themes) == 1: selected_ui_name = theme_ui_map.get(available_themes[0], (available_themes[0], ""))[0] selected_theme = ui_to_theme_map[selected_ui_name] print(f"\n추천 가능한 테마가 1개이므로 자동 선택: {selected_ui_name}") else: sel = int(input("\n원하는 테마 번호를 선택하세요: ")) - 1 selected_ui_name = theme_ui_map.get(available_themes[sel], (available_themes[sel], ""))[0] selected_theme = ui_to_theme_map[selected_ui_name] print(f"\n선택하신 테마: {selected_ui_name}") # 해당 테마 기준 최종 추천 theme_df = all_theme_df[all_theme_df["통합테마명"] == selected_theme] theme_df = theme_df.drop_duplicates(subset=["여행지"]) result_df = apply_weighted_score_filter(theme_df) ###추가### if len(result_df) < 3: fallback = travel_df[~travel_df['여행지'].isin(result_df['여행지'])].drop_duplicates(subset=["여행지"]) if not fallback.empty: fill = fallback.sample(n=min(3 - len(result_df), len(fallback)), random_state=random.randint(1, 9999)) result_df = pd.concat([result_df, fill], ignore_index=True) # 4. 결과 출력 if intent_score < 0.7: # 감정 기반인 경우에만 출력 ui_name = theme_ui_map.get(selected_theme, (selected_theme,))[0] opening_line_template = theme_opening_lines.get(ui_name) if opening_line_template: print(f"\n{opening_line_template.format(len(result_df))}") print("\n[최종 추천 여행지]") for idx, row in enumerate(result_df.itertuples(), 1): country = row.여행나라 city = row.여행도시 name = row.여행지 desc = row.한줄설명 if hasattr(row, '한줄설명') else "설명이 없습니다" if country == city: loc = f"{country}" else: loc = f"{country}, {city}" print(f"{idx}. {name} ({loc}) - {desc}") recommend_names = result_df["여행지"].tolist() print("\n👉 마음에 드는 여행지를 골라주세요:") print(" ".join(f"[{name}]" for name in recommend_names)) try: sel = int(input("\n원하는 여행지 번호를 선택하세요:")) - 1 if 0 <= sel < len(recommend_names): selected_place = recommend_names[sel] print(f"\n🎉 '{selected_place}'를 선택하셨습니다. 멋진 여행 되세요!") else: print("\n⚠️ 올바른 번호를 입력해주세요.") except ValueError: print("\n⚠️ 숫자로 된 번호를 입력해주세요.") # In[12]: # if __name__ == "__main__": #main() # In[ ]: