MOAI / chat_a.py
code-slicer's picture
Update chat_a.py
abd0954 verified
raw
history blame
75.7 kB
# -*- 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}. <strong>{title}</strong><br> 🅼 {desc}<br> {tags}<br> \
<a href="{row.URL}" target="_blank" rel="noopener noreferrer"
style="text-decoration:none;font-weight:600;color:#009c75;">
💚 바로가기&nbsp;↗
</a>"""
)
# ✅ 출력
if recommend_texts:
full_message = "🧳 이런 패키지를 추천드려요:<br><br>" + "<br><br>".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+"<br>")
##수정##
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 = "<br>".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[ ]: