MOAI / app.py
code-slicer's picture
Update app.py
7030d57 verified
raw
history blame
66.3 kB
# -*- coding: utf-8 -*-
# ──────────────────────────────── BOOTSTRAP (must be first) ────────────────────────────────
import os, pathlib, io, json, random
HOME = pathlib.Path.home() # ✅ 실행 사용자 홈 디렉터리 (쓰기 가능)
APP_DIR = pathlib.Path(__file__).parent.resolve()
# Streamlit 홈/설정
STREAMLIT_DIR = HOME / ".streamlit"
STREAMLIT_DIR.mkdir(parents=True, exist_ok=True)
os.environ["STREAMLIT_HOME"] = str(STREAMLIT_DIR)
os.environ["STREAMLIT_SERVER_HEADLESS"] = "true"
os.environ["STREAMLIT_BROWSER_GATHER_USAGE_STATS"] = "false"
# ✅ HF/Transformers 캐시: 홈 밑의 .cache 사용 (필요 시 HF_CACHE_ROOT로 오버라이드 가능)
CACHE_ROOT = pathlib.Path(os.environ.get("HF_CACHE_ROOT", HOME / ".cache" / f"u{os.getuid()}"))
HF_HOME = CACHE_ROOT / "hf-home"
TRANSFORMERS_CACHE = CACHE_ROOT / "hf-cache"
HUB_CACHE = CACHE_ROOT / "hf-cache"
TORCH_HOME = CACHE_ROOT / "torch-cache"
XDG_CACHE_HOME = CACHE_ROOT / "xdg-cache"
# 폴더 생성 (권한 오류가 나면 /tmp로 자동 폴백)
try:
for p in [HF_HOME, TRANSFORMERS_CACHE, HUB_CACHE, TORCH_HOME, XDG_CACHE_HOME]:
p.mkdir(parents=True, exist_ok=True)
except PermissionError:
TMP_ROOT = pathlib.Path("/tmp") / f"hf-cache-u{os.getuid()}"
HF_HOME = TMP_ROOT / "hf-home"
TRANSFORMERS_CACHE = TMP_ROOT / "hf-cache"
HUB_CACHE = TMP_ROOT / "hf-cache"
TORCH_HOME = TMP_ROOT / "torch-cache"
XDG_CACHE_HOME = TMP_ROOT / "xdg-cache"
for p in [HF_HOME, TRANSFORMERS_CACHE, HUB_CACHE, TORCH_HOME, XDG_CACHE_HOME]:
p.mkdir(parents=True, exist_ok=True)
os.environ["HF_HOME"] = str(HF_HOME)
os.environ["TRANSFORMERS_CACHE"] = str(TRANSFORMERS_CACHE)
os.environ["HUGGINGFACE_HUB_CACHE"] = str(HUB_CACHE)
os.environ["TORCH_HOME"] = str(TORCH_HOME)
os.environ["XDG_CACHE_HOME"] = str(XDG_CACHE_HOME)
os.environ.setdefault("TOKENIZERS_PARALLELISM", "false")
os.environ.setdefault("HF_HUB_ENABLE_HF_TRANSFER", "1")
from huggingface_hub import hf_hub_download
import pandas as pd
import streamlit as st
from streamlit.components.v1 import html
from css import render_message, render_chip_buttons, log_and_render, replay_log, _get_colors
st.success("🎉 앱이 성공적으로 시작되었습니다! 라이브러리 설치 성공!")
# ──────────────────────────────── Dataset Repo 설정 ────────────────────────────────
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 # e.g. xet 포인터
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:
# hub_filename 생략 시 로컬 파일명 사용
if hub_filename is None:
hub_filename = os.path.basename(local_path)
# 1) 로컬 우선
if os.path.exists(local_path):
with open(local_path, "rb") as f:
data = f.read()
if not _is_pointer_bytes(data):
return _read_csv_bytes(data)
# 2) 허브 다운로드
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")
def load_json_smart(local_path: str,
hub_filename: str | None = None,
repo_id: str = HF_DATASET_REPO,
repo_type: str = "dataset",
revision: str = HF_DATASET_REV):
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):
return json.loads(data.decode("utf-8"))
cached = hf_hub_download(repo_id=repo_id, filename=hub_filename,
repo_type=repo_type, revision=revision)
with open(cached, "r", encoding="utf-8") as f:
return json.load(f)
# ──────────────────────────────── 데이터 로드 ────────────────────────────────
travel_df = load_csv_smart("trip_emotions.csv", "trip_emotions.csv")
external_score_df = load_csv_smart("external_scores.csv", "external_scores.csv")
festival_df = load_csv_smart("festivals.csv", "festivals.csv")
weather_df = load_csv_smart("weather.csv", "weather.csv")
package_df = load_csv_smart("packages.csv", "packages.csv")
master_df = load_csv_smart("countries_cities.csv", "countries_cities.csv")
theme_title_phrases = load_json_smart("theme_title_phrases.json", "theme_title_phrases.json")
# 필수 컬럼 가드
for col in ("여행나라", "여행도시", "여행지"):
if col not in travel_df.columns:
st.error(f"'travel_df'에 '{col}' 컬럼이 없습니다. 실제 컬럼: {travel_df.columns.tolist()}")
st.stop()
# ──────────────────────────────── chat_a import & 초기화 ────────────────────────────────
from chat_a import (
init_datasets, # ⬅️ 새로 추가된 지연 초기화 함수
analyze_emotion,
detect_intent,
extract_themes,
recommend_places_by_theme,
detect_location_filter,
generate_intro_message,
theme_ui_map,
ui_to_theme_map,
theme_opening_lines,
intent_opening_lines,
apply_weighted_score_filter,
get_highlight_message,
get_weather_message,
get_intent_intro_message,
recommend_packages,
handle_selected_place,
generate_region_intro,
parse_companion_and_age,
filter_packages_by_companion_age,
make_top2_description_custom,
format_summary_tags_custom,
make_companion_age_message
)
# 지연 초기화: import 시점에는 데이터 접근 금지, 여기서 한 번만 주입
init_datasets(
travel_df=travel_df,
festival_df=festival_df,
external_score_df=external_score_df,
weather_df=weather_df,
package_df=package_df,
master_df=master_df,
theme_title_phrases=theme_title_phrases,
)
# ───────────────────────────────────── streamlit용 함수
def init_session():
if "chat_log" not in st.session_state:
st.session_state.chat_log = []
if "mode" not in st.session_state:
st.session_state.mode = None
if "user_input" not in st.session_state:
st.session_state.user_input = ""
if "selected_theme" not in st.session_state:
st.session_state.selected_theme = None
def make_key(row) -> tuple[str, str]:
"""prev 에 넣고 꺼낼 때 쓰는 고유키(여행지, 여행도시)"""
return (row["여행지"], row["여행도시"])
# ───────────────────────────────────── streamlit 영역 선언
st.set_page_config(page_title="여행은 모두투어 : 모아(MoAi)", layout="centered")
accent = _get_colors().get("accent", "#0B8A5A")
st.markdown(
f"""
<h3 style="color:{accent}; font-weight:1000; margin:0.25rem 0 1rem;">
🅼 여행은 모두투어, 추천은 모아(MoAi)
</h3>
""",
unsafe_allow_html=True,
)
# Unsplash 이미지: source.unsplash.com은 사진 ID로 직접 이미지를 서빙합니다.
BG_URL = "https://plus.unsplash.com/premium_photo-1679830513869-cd3648acb1db?q=80&w=2127&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D" # 해상도는 원하시는 대로 조정
st.markdown(f"""
<style>
/* 1) 가운데 컬럼(.block-container)만 배경 사용 */
.block-container {{
position: relative;
min-height: 100vh; /* 화면 높이 만큼만(=빨간 박스 높이 느낌) */
}}
.block-container::before {{
content: "";
position: absolute; inset: 0; /* 컬럼 영역에만 덮기 */
background: url('{BG_URL}') center / cover no-repeat;
background-position: center 35%;
opacity: .1; /* 투명도 80% */
pointer-events: none;
border-radius: 0px; /* 모서리 둥글게(원하면 제거) */
z-index: 0;
}}
/* 2) 실제 콘텐츠가 배경 위에 오도록 */
.block-container > * {{ position: relative; z-index: 1; }}
/* 모바일에선 고정 배경 이슈 방지 */
@media (max-width: 768px){{
.block-container::before{{ background-attachment: initial; }}
}}
</style>
""", unsafe_allow_html=True)
# ── P 글꼴 크기 14 px ───────────────────────────────────
st.markdown("""
<style>
/* 기본 p 태그 글꼴 크기 */
html, body, p {
font-size: 14px !important; /* ← 14 px 고정 */
line-height: 1.5; /* (선택) 가독성을 위한 줄간격 */
}
/* Streamlit 기본 마진 제거로 불필요한 여백 방지 (선택) */
p {
margin-top: 0;
margin-bottom: 0.5rem;
}
</style>
""", unsafe_allow_html=True)
# ───────────────────────────────────── region mode
def region_ui(travel_df, external_score_df, festival_df, weather_df, package_df,
country_filter, city_filter, chat_container, log_and_render):
"""region 모드(특정 나라, 도시를 직접 언급했을 경우) 전용 UI & 로직"""
# ────────────────── 세션 키 정의
region_key = "region_chip_selected"
prev_key = "region_prev_recommended"
step_key = "region_step"
sample_key = "region_sample_df"
# ────────────────── 0) 초기화
if step_key not in st.session_state:
st.session_state[step_key] = "recommend"
st.session_state[prev_key] = set()
st.session_state.pop(sample_key, None)
# ────────────────── 1) restart 상태면 인트로만 출력하고 종료
if st.session_state[step_key] == "restart":
log_and_render(
"다시 여행지를 추천해드릴게요!<br>요즘 떠오르는 여행이 있으신가요?",
sender="bot",
chat_container=chat_container,
key="region_restart_intro"
)
return
# ────────────────── 2) 추천 단계
if st.session_state[step_key] == "recommend":
# 2.1) 추천 문구 출력 (도시 또는 국가 기준)
city_exists = bool(city_filter) and city_filter in travel_df["여행도시"].values
country_exists = bool(country_filter) and country_filter in travel_df["여행나라"].values
# 존재하지 않는 도시인 경우
if city_filter and not city_exists:
intro = generate_region_intro('', country_filter)
log_and_render(
f"죄송해요. {city_filter}의 여행지는 아직 미정이에요.<br>하지만, {intro}",
sender="bot",
chat_container=chat_container,
key="region_intro_invalid"
)
else:
# 정상적인 도시/국가일 경우
intro = generate_region_intro(city_filter, country_filter)
log_and_render(intro,
sender="bot",
chat_container=chat_container,
key="region_intro")
# 2.2) 여행지 후보 목록 필터링
df = travel_df.drop_duplicates(subset=["여행지"])
if city_exists:
df = df[df["여행도시"].str.contains(city_filter, na=False)]
elif country_exists:
df = df[df["여행나라"].str.contains(country_filter, na=False)]
# 2.3) 이전 추천 목록과 겹치지 않는 여행지만 남김
prev = st.session_state.setdefault(prev_key, set())
remaining = df[~df.apply(lambda r: make_key(r) in prev, axis=1)]
# 추천 가능한 여행지가 없다면 종료 단계로 전환
if remaining.empty and sample_key not in st.session_state:
st.session_state[step_key] = "recommand_end"
st.rerun()
return
# 2.4) 샘플링 (이전 샘플이 없거나 비어 있으면 새로 추출)
if sample_key not in st.session_state or st.session_state[sample_key].empty:
sampled = remaining.sample(
n=min(3, len(remaining)), #최대 3개
random_state=random.randint(1, 9999)
)
st.session_state[sample_key] = sampled
# tuple 형태로 한꺼번에 추가
prev.update([make_key(r) for _, r in sampled.iterrows()])
st.session_state[prev_key] = prev
else:
sampled = st.session_state[sample_key]
loc_df = st.session_state[sample_key]
# 2.5) 추천 리스트 출력 & 칩 UI
message = (
"📌 추천 여행지 목록<br>가장 가고 싶은 곳을 골라주세요!<br><br>" +
"<br>".join([
f"{i+1}. <strong>{row.여행지}</strong> "
f"({row.여행나라}, {row.여행도시}) "
f"{getattr(row, '한줄설명', '설명이 없습니다')}"
for i, row in enumerate(loc_df.itertuples())
])
)
with chat_container:
log_and_render(message,
sender="bot",
chat_container=chat_container,
key=f"region_recommendation_{random.randint(1,999999)}"
)
# 칩 버튼으로 추천지 중 선택받기
prev_choice = st.session_state.get(region_key, None)
choice = render_chip_buttons(
loc_df["여행지"].tolist() + ["다른 여행지 보기 🔄"],
key_prefix="region_chip",
selected_value=prev_choice
)
# 2.7) 선택 결과 처리
if not choice or choice == prev_choice:
return
if choice == "다른 여행지 보기 🔄":
log_and_render("다른 여행지 보기 🔄",
sender="user",
chat_container=chat_container,
key=f"user_place_refresh_{random.randint(1,999999)}")
st.session_state.pop(sample_key, None)
st.rerun()
return
# 2.8) 여행지 선택 완료
st.session_state[region_key] = choice
st.session_state[step_key] = "detail"
st.session_state.chat_log.append(("user", choice))
# 실제로 선택된 여행지만 prev에 기록
match = sampled[sampled["여행지"] == choice]
if not match.empty:
prev.add(make_key(match.iloc[0]))
st.session_state[prev_key] = prev
# 샘플 폐기
st.session_state.pop(sample_key, None)
st.rerun()
return
# ────────────────── 3) 추천 종료 단계: 더 이상 추천할 여행지가 없을 때
elif st.session_state[step_key] == "recommand_end":
with chat_container:
# 3.1) 메시지 출력
log_and_render(
"⚠️ 더 이상 새로운 여행지가 없어요.<br>다시 질문하시겠어요?",
sender="bot",
chat_container=chat_container,
key="region_empty"
)
# 3.2) 재시작 여부 칩 버튼 출력
restart_done_key = "region_restart_done"
chip_ph = st.empty()
if not st.session_state.get(restart_done_key, False):
with chip_ph:
choice = render_chip_buttons(
["예 🔄", "아니오 ❌"],
key_prefix="region_restart"
)
else:
choice = None
# 3.3) 아직 아무것도 선택하지 않은 경우
if choice is None:
return
chip_ph.empty()
st.session_state[restart_done_key] = True
# 3.4) 사용자 선택값 출력
log_and_render(
choice,
sender="user",
chat_container=chat_container,
key=f"user_restart_choice_{choice}"
)
# 3.5) 사용자가 재추천을 원하는 경우
if choice == "예 🔄":
# 여행 추천 상태 초기화
for k in [region_key, prev_key, sample_key, restart_done_key]:
st.session_state.pop(k, None)
chip_ph.empty()
# 다음 추천 단계로 초기화
st.session_state["user_input_rendered"] = False
st.session_state["region_step"] = "restart"
log_and_render(
"다시 여행지를 추천해드릴게요!<br>요즘 떠오르는 여행이 있으신가요?",
sender="bot",
chat_container=chat_container,
key="region_restart_intro"
)
return
# 3.6) 사용자가 종료를 선택한 경우
else:
log_and_render("여행 추천을 종료할게요. 필요하실 때 언제든지 또 찾아주세요! ✈️",
sender="bot",
chat_container=chat_container,
key="region_exit")
st.stop()
return
# ────────────────── 4) 여행지 상세 단계
if st.session_state[step_key] == "detail":
chosen = st.session_state[region_key]
# city 이름 뽑아서 세션에 저장
row = travel_df[travel_df["여행지"] == chosen].iloc[0]
st.session_state["selected_city"] = row["여행도시"]
st.session_state["selected_place"] = chosen
log_and_render(chosen,
sender="user",
chat_container=chat_container,
key=f"user_place_{chosen}")
handle_selected_place(
chosen,
travel_df,
external_score_df,
festival_df,
weather_df,
chat_container=chat_container
)
st.session_state[step_key] = "companion"
st.rerun()
return
# ────────────────── 5) 동행·연령 받기 단계
elif st.session_state[step_key] == "companion":
with chat_container:
# 5.1) 안내 메시지 출력
log_and_render(
"함께 가는 분이나 연령대를 알려주시면 더 딱 맞는 상품을 골라드릴게요!<br>"
"1️⃣ 동행 여부 (혼자 / 친구 / 커플 / 가족 / 단체)<br>"
"2️⃣ 연령대 (20대 / 30대 / 40대 / 50대 / 60대 이상)",
sender="bot",
chat_container=chat_container,
key="ask_companion_age"
)
# 5.1.1) 동행 체크박스
st.markdown(
'<div style="font-size:14px; font-weight:600; margin:14px 0px 6px 0px;">👫 동행 선택</div>',
unsafe_allow_html=True
)
c_cols = st.columns(5)
comp_flags = {
"혼자": c_cols[0].checkbox("혼자"),
"친구": c_cols[1].checkbox("친구"),
"커플": c_cols[2].checkbox("커플"),
"가족": c_cols[3].checkbox("가족"),
"단체": c_cols[4].checkbox("단체"),
}
companions = [k for k, v in comp_flags.items() if v]
# 5.1.2) 연령 체크박스
st.markdown(
'<div style="font-size:14px; font-weight:600; margin:0px 0px 6px 0px;">🎂 연령 선택</div>',
unsafe_allow_html=True
)
a_cols = st.columns(5)
age_flags = {
"20대": a_cols[0].checkbox("20대"),
"30대": a_cols[1].checkbox("30대"),
"40대": a_cols[2].checkbox("40대"),
"50대": a_cols[3].checkbox("50대"),
"60대 이상": a_cols[4].checkbox("60대 이상"),
}
age_group = [k for k, v in age_flags.items() if v]
# 5.1.3) 확인 버튼
confirm = st.button(
"추천 받기",
key="btn_confirm_companion",
disabled=not (companions or age_group),
)
# 5.2) 메시지 출력
if confirm:
# 사용자 버블 출력
user_msg = " / ".join(companions + age_group)
log_and_render(
user_msg if user_msg else "선택 안 함",
sender="user",
chat_container=chat_container,
key=f"user_comp_age_{random.randint(1,999999)}"
)
# 세션 저장
st.session_state["companions"] = companions or None
st.session_state["age_group"] = age_group or None
# 다음 스텝
st.session_state[step_key] = "package"
st.rerun()
return
# ────────────────── 6) 동행·연령 필터링· 패키지 출력 단계
elif st.session_state[step_key] == "package":
# 패키지 버블을 이미 만들었으면 건너뜀
if st.session_state.get("package_rendered", False):
st.session_state[step_key] = "package_end"
return
companions = st.session_state.get("companions")
age_group = st.session_state.get("age_group")
city = st.session_state.get("selected_city")
place = st.session_state.get("selected_place")
filtered = filter_packages_by_companion_age(
package_df, companions, age_group, city=city, top_n=2
)
if filtered.empty:
log_and_render(
"⚠️ 아쉽지만 지금 조건에 맞는 패키지가 없어요.<br>"
"다른 조건으로 다시 찾아볼까요?",
sender="bot", chat_container=chat_container,
key="no_package"
)
st.session_state[step_key] = "companion" # 다시 입력 단계로
st.rerun()
return
combo_msg = make_companion_age_message(companions, age_group)
header = f"{combo_msg}"
# 패키지 카드 출력
used_phrases = set()
theme_row = travel_df[travel_df["여행지"] == place]
raw_theme = theme_row["통합테마명"].iloc[0] if not theme_row.empty else None
selected_ui_theme = theme_ui_map.get(raw_theme, (raw_theme,))[0]
title_candidates = theme_title_phrases.get(selected_ui_theme, ["추천"])
sampled_titles = random.sample(title_candidates,
k=min(2, len(title_candidates)))
# 메시지 생성
pkg_msgs = [header]
for i, (_, row) in enumerate(filtered.iterrows(), 1):
desc, used_phrases = make_top2_description_custom(
row.to_dict(), used_phrases
)
tags = format_summary_tags_custom(row["요약정보"])
title_phrase = (sampled_titles[i-1] if i <= len(sampled_titles)
else random.choice(title_candidates))
title = f"{city} {title_phrase} 패키지"
url = row.URL
pkg_msgs.append(
f"{i}. <strong>{title}</strong><br>"
f"🅼 {desc}<br>{tags}<br>"
f'<a href="{url}" target="_blank" rel="noopener noreferrer" '
'style="text-decoration:none;font-weight:600;color:#009c75;">'
'💚 바로가기&nbsp;↗</a>'
)
# 메시지 출력
log_and_render(
"<br><br>".join(pkg_msgs),
sender="bot",
chat_container=chat_container,
key=f"pkg_bundle_{random.randint(1,999999)}"
)
# 세션 정리
st.session_state["package_rendered"] = True
st.session_state[step_key] = "package_end"
return
# ────────────────── 7) 종료 단계
elif st.session_state[step_key] == "package_end":
log_and_render("필요하실 때 언제든지 또 찾아주세요! ✈️",
sender="bot", chat_container=chat_container,
key="goodbye")
# ───────────────────────────────────── intent 모드
def intent_ui(travel_df, external_score_df, festival_df, weather_df, package_df,
country_filter, city_filter, chat_container, intent, log_and_render):
"""intent(의도를 입력했을 경우) 모드 전용 UI & 로직"""
# ────────────────── 세션 키 정의
sample_key = "intent_sample_df"
step_key = "intent_step"
prev_key = "intent_prev_places"
intent_key = "intent_chip_selected"
# ────────────────── 0) 초기화
if step_key not in st.session_state:
st.session_state[step_key] = "recommend_places"
st.session_state[prev_key] = set()
st.session_state.pop(sample_key, None)
# ────────────────── 1) restart 상태면 인트로만 출력하고 종료
if st.session_state[step_key] == "restart":
log_and_render(
"다시 여행지를 추천해드릴게요!<br>요즘 떠오르는 여행이 있으신가요?",
sender="bot",
chat_container=chat_container,
key="region_restart_intro"
)
return
# ────────────────── 2) 여행지 추천 단계
if st.session_state[step_key] == "recommend_places":
selected_theme = intent
theme_df = recommend_places_by_theme(selected_theme, country_filter, city_filter)
theme_df = theme_df.drop_duplicates(subset=["여행도시"])
theme_df = theme_df.drop_duplicates(subset=["여행지"])
# 2.1) 이전 추천 기록 세팅
prev = st.session_state.setdefault(prev_key, set())
# 2.2) 이미 샘플이 있다면 result_df 재사용
if sample_key in st.session_state and not st.session_state[sample_key].empty:
result_df = st.session_state[sample_key]
else:
# 2.3) 새로운 추천 대상 필터링
candidates = theme_df[~theme_df["여행지"].isin(prev)]
# 2.4) 후보가 없다면 종료
if candidates.empty:
st.session_state[step_key] = "recommend_places_end"
st.rerun()
return
# 2.5) 새로운 추천 추출 및 저장
result_df = apply_weighted_score_filter(candidates)
st.session_state[sample_key] = result_df
# prev에 등록하여 중복 추천 방지
prev.update(result_df["여행지"])
st.session_state[prev_key] = prev
# 2.6) 오프닝 문장 생성
opening_line = intent_opening_lines.get(selected_theme, f"'{selected_theme}' 여행지를 소개할게요.")
opening_line = opening_line.format(len(result_df))
# 2.7) 추천 메시지 구성
message = "<br>".join([
f"{i+1}. <strong>{row.여행지}</strong> "
f"({row.여행나라}, {row.여행도시}) "
f"{getattr(row, '한줄설명', '설명이 없습니다')}"
for i, row in enumerate(result_df.itertuples())
])
# 2.8) 챗봇 출력 + 칩 버튼 렌더링
with chat_container:
log_and_render(f"{opening_line}<br><br>{message}",
sender="bot",
chat_container=chat_container,
key=f"intent_recommendation_{random.randint(1,999999)}")
recommend_names = result_df["여행지"].tolist()
prev_choice = st.session_state.get(intent_key, None)
choice = render_chip_buttons(
recommend_names + ["다른 여행지 보기 🔄"],
key_prefix="intent_chip",
selected_value=prev_choice
)
# 2.9) 선택 없거나 중복 선택이면 대기
if not choice or choice == prev_choice:
return
# 선택 결과 처리
if choice:
if choice == "다른 여행지 보기 🔄":
log_and_render("다른 여행지 보기 🔄",
sender="user",
chat_container=chat_container,
key=f"user_place_refresh_{random.randint(1,999999)}")
st.session_state.pop(sample_key, None)
st.rerun()
return
# 정상 선택된 경우
st.session_state[intent_key] = choice
st.session_state[step_key] = "detail"
st.session_state.chat_log.append(("user", choice))
# 실제로 선택된 여행지만 prev에 기록
match = result_df[result_df["여행지"] == choice]
if not match.empty:
prev.add(choice)
st.session_state[prev_key] = prev
# 샘플 폐기
st.session_state.pop(sample_key, None)
st.rerun()
return
# ────────────────── 3) 추천 종료 단계
elif st.session_state[step_key] == "recommend_places_end":
# 3.1) 메시지 출력
with chat_container:
log_and_render(
"⚠️ 더 이상 새로운 여행지가 없어요.<br>다시 질문하시겠어요?",
sender="bot",
chat_container=chat_container,
key="intent_empty"
)
# 3.2) 재시작 여부 칩 버튼 출력
restart_done_key = "intent_restart_done"
chip_ph = st.empty()
if not st.session_state.get(restart_done_key, False):
with chip_ph:
choice = render_chip_buttons(
["예 🔄", "아니오 ❌"],
key_prefix="intent_restart")
else:
choice = None
# 3.3) 아직 아무것도 선택하지 않은 경우
if choice is None:
return
chip_ph.empty()
st.session_state[restart_done_key] = True
# 3.4) 사용자 선택값 출력
log_and_render(choice,
sender="user",
chat_container=chat_container
)
# 3.5) 사용자가 재추천을 원하는 경우
if choice == "예 🔄":
for k in [sample_key, prev_key, intent_key, restart_done_key]:
st.session_state.pop(k, None)
chip_ph.empty()
# 다음 추천 단계로 초기화
st.session_state["user_input_rendered"] = False
st.session_state["intent_step"] = "restart"
log_and_render(
"다시 여행지를 추천해드릴게요!<br>요즘 떠오르는 여행이 있으신가요?",
sender="bot",
chat_container=chat_container,
key="intent_restart_intro"
)
return
# 3.6) 사용자가 종료를 선택한 경우
else:
log_and_render("여행 추천을 종료할게요. 필요하실 때 언제든지 또 찾아주세요! ✈️",
sender="bot",
chat_container=chat_container,
key="intent_exit")
st.stop()
return
# ────────────────── 4) 여행지 상세 단계
if st.session_state[step_key] == "detail":
chosen = st.session_state[intent_key]
# city 이름 뽑아서 세션에 저장
row = travel_df[travel_df["여행지"] == chosen].iloc[0]
st.session_state["selected_city"] = row["여행도시"]
st.session_state["selected_place"] = chosen
log_and_render(chosen,
sender="user",
chat_container=chat_container,
key=f"user_place_{chosen}")
handle_selected_place(
chosen,
travel_df,
external_score_df,
festival_df,
weather_df,
chat_container=chat_container
)
st.session_state[step_key] = "companion"
st.rerun()
return
# ────────────────── 5) 동행·연령 받기 단계
elif st.session_state[step_key] == "companion":
with chat_container:
# 5.1) 안내 메시지 출력
log_and_render(
"함께 가는 분이나 연령대를 알려주시면 더 딱 맞는 상품을 골라드릴게요!<br>"
"1️⃣ 동행 여부 (혼자 / 친구 / 커플 / 가족 / 단체)<br>"
"2️⃣ 연령대 (20대 / 30대 / 40대 / 50대 / 60대 이상)",
sender="bot",
chat_container=chat_container,
key="ask_companion_age"
)
# 5.1.1) 동행 체크박스
st.markdown(
'<div style="font-size:14px; font-weight:600; margin:14px 0px 6px 0px;">👫 동행 선택</div>',
unsafe_allow_html=True
)
c_cols = st.columns(5)
comp_flags = {
"혼자": c_cols[0].checkbox("혼자"),
"친구": c_cols[1].checkbox("친구"),
"커플": c_cols[2].checkbox("커플"),
"가족": c_cols[3].checkbox("가족"),
"단체": c_cols[4].checkbox("단체"),
}
companions = [k for k, v in comp_flags.items() if v]
# 5.1.2) 연령 체크박스
st.markdown(
'<div style="font-size:14px; font-weight:600; margin:0px 0px 6px 0px;">🎂 연령 선택</div>',
unsafe_allow_html=True
)
a_cols = st.columns(5)
age_flags = {
"20대": a_cols[0].checkbox("20대"),
"30대": a_cols[1].checkbox("30대"),
"40대": a_cols[2].checkbox("40대"),
"50대": a_cols[3].checkbox("50대"),
"60대 이상": a_cols[4].checkbox("60대 이상"),
}
age_group = [k for k, v in age_flags.items() if v]
# 5.1.3) 확인 버튼
confirm = st.button(
"추천 받기",
key="btn_confirm_companion",
disabled=not (companions or age_group),
)
# 5.2) 메시지 출력
if confirm:
# 사용자 버블 출력
user_msg = " / ".join(companions + age_group)
log_and_render(
user_msg if user_msg else "선택 안 함",
sender="user",
chat_container=chat_container,
key=f"user_comp_age_{random.randint(1,999999)}"
)
# 세션 저장
st.session_state["companions"] = companions or None
st.session_state["age_group"] = age_group or None
# 다음 스텝
st.session_state[step_key] = "package"
st.rerun()
return
# ────────────────── 6) 동행·연령 필터링· 패키지 출력 단계
elif st.session_state[step_key] == "package":
# 패키지 버블을 이미 만들었으면 건너뜀
if st.session_state.get("package_rendered", False):
st.session_state[step_key] = "package_end"
return
companions = st.session_state.get("companions")
age_group = st.session_state.get("age_group")
city = st.session_state.get("selected_city")
place = st.session_state.get("selected_place")
filtered = filter_packages_by_companion_age(
package_df, companions, age_group, city=city, top_n=2
)
if filtered.empty:
log_and_render(
"⚠️ 아쉽지만 지금 조건에 맞는 패키지가 없어요.<br>"
"다른 조건으로 다시 찾아볼까요?",
sender="bot", chat_container=chat_container,
key="no_package"
)
st.session_state[step_key] = "companion" # 다시 입력 단계로
st.rerun()
return
combo_msg = make_companion_age_message(companions, age_group)
header = f"{combo_msg}"
# 패키지 카드 출력
used_phrases = set()
theme_row = travel_df[travel_df["여행지"] == place]
raw_theme = theme_row["통합테마명"].iloc[0] if not theme_row.empty else None
selected_ui_theme = theme_ui_map.get(raw_theme, (raw_theme,))[0]
title_candidates = theme_title_phrases.get(selected_ui_theme, ["추천"])
sampled_titles = random.sample(title_candidates,
k=min(2, len(title_candidates)))
# 메시지 생성
pkg_msgs = [header]
for i, (_, row) in enumerate(filtered.iterrows(), 1):
desc, used_phrases = make_top2_description_custom(
row.to_dict(), used_phrases
)
tags = format_summary_tags_custom(row["요약정보"])
title_phrase = (sampled_titles[i-1] if i <= len(sampled_titles)
else random.choice(title_candidates))
title = f"{city} {title_phrase} 패키지"
url = row.URL
pkg_msgs.append(
f"{i}. <strong>{title}</strong><br>"
f"🅼 {desc}<br>{tags}<br>"
f'<a href="{url}" target="_blank" rel="noopener noreferrer" '
'style="text-decoration:none;font-weight:600;color:#009c75;">'
'💚 바로가기&nbsp;↗</a>'
)
# 메시지 출력
log_and_render(
"<br><br>".join(pkg_msgs),
sender="bot",
chat_container=chat_container,
key=f"pkg_bundle_{random.randint(1,999999)}"
)
# 세션 정리
st.session_state["package_rendered"] = True
st.session_state[step_key] = "package_end"
return
# ────────────────── 7) 종료 단계
elif st.session_state[step_key] == "package_end":
log_and_render("필요하실 때 언제든지 또 찾아주세요! ✈️",
sender="bot", chat_container=chat_container,
key="goodbye")
# ───────────────────────────────────── emotion 모드
def emotion_ui(travel_df, external_score_df, festival_df, weather_df, package_df,
country_filter, city_filter, chat_container, candidate_themes,
intent, emotion_groups, top_emotions, log_and_render):
"""emotion(감정을 입력했을 경우) 모드 전용 UI & 로직"""
# ────────────────── 세션 키 정의
sample_key = "emotion_sample_df"
step_key = "emotion_step"
theme_key = "selected_theme"
emotion_key = "emotion_chip_selected"
prev_key = "emotion_prev_places"
# ────────────────── 0) 초기화
if step_key not in st.session_state:
st.session_state[step_key] = "theme_selection"
st.session_state[prev_key] = set()
st.session_state.pop(sample_key, None)
# ────────────────── 1) restart 상태면 인트로만 출력하고 종료
if st.session_state[step_key] == "restart":
log_and_render(
"다시 여행지를 추천해드릴게요!<br>요즘 떠오르는 여행이 있으신가요?",
sender="bot",
chat_container=chat_container,
key="region_restart_intro"
)
return
# ────────────────── 2) 테마 추천 단계
if st.session_state[step_key] == "theme_selection":
# 추천 테마 1개일 경우
if len(candidate_themes) == 1:
selected_theme = candidate_themes[0]
st.session_state[theme_key] = selected_theme
log_and_render(f"추천 가능한 테마가 1개이므로 '{selected_theme}'을 선택할게요.", sender="bot", chat_container=chat_container)
st.session_state[step_key] = "recommend_places"
st.rerun()
# 테마가 여러 개일 경우
else:
# 인트로 메시지
intro_msg = generate_intro_message(intent=intent, emotion_groups=emotion_groups, emotion_scores=top_emotions)
log_and_render(f"{intro_msg}<br>아래 중 마음이 끌리는 여행 스타일을 골라주세요 💫", sender="bot", chat_container=chat_container)
# 후보 테마 준비
dfs = [recommend_places_by_theme(t, country_filter, city_filter) for t in candidate_themes]
dfs = [df for df in dfs if not df.empty]
all_theme_df = pd.concat(dfs) if dfs else pd.DataFrame(columns=travel_df.columns)
all_theme_df = all_theme_df.drop_duplicates(subset=["여행지"])
all_theme_names = all_theme_df["통합테마명"].dropna().tolist()
available_themes = []
for t in candidate_themes:
if t in all_theme_names and t not in available_themes:
available_themes.append(t)
for t in all_theme_names:
if t not in available_themes:
available_themes.append(t)
available_themes = available_themes[:3] # 최대 3개
# 칩 UI 출력
with chat_container:
chip = render_chip_buttons(
[theme_ui_map.get(t, (t, ""))[0] for t in available_themes],
key_prefix="theme_chip"
)
# 선택이 완료되면 다음 단계로 이동
if chip:
selected_theme = ui_to_theme_map.get(chip, chip)
st.session_state[theme_key] = selected_theme
st.session_state[step_key] = "recommend_places"
st.session_state["emotion_all_theme_df"] = all_theme_df
log_and_render(f"{chip}", sender="user",
chat_container=chat_container)
st.rerun()
# ────────────────── 3) 여행지 추천 단계
if st.session_state[step_key] == "recommend_places":
all_theme_df = st.session_state.get("emotion_all_theme_df", pd.DataFrame())
selected_theme = st.session_state.get(theme_key, "")
prev_key = "emotion_prev_places"
prev = st.session_state.setdefault(prev_key, set())
# 예외 처리: 데이터 없을 경우
if all_theme_df.empty or not selected_theme:
log_and_render("추천 데이터를 불러오는 데 문제가 발생했어요. <br>다시 입력해 주세요.", sender="bot", chat_container=chat_container)
return
if sample_key not in st.session_state:
theme_df = all_theme_df[all_theme_df["통합테마명"] == selected_theme]
theme_df = theme_df.drop_duplicates(subset=["여행도시"])
theme_df = theme_df.drop_duplicates(subset=["여행지"])
remaining = theme_df[~theme_df["여행지"].isin(prev)]
if remaining.empty:
st.session_state[step_key] = "recommend_places_end"
st.rerun()
return
result_df = apply_weighted_score_filter(remaining)
st.session_state[sample_key] = result_df
else:
result_df = st.session_state[sample_key]
# 추천 수 부족할 경우 Fallback 보완
if len(result_df) < 3:
fallback = travel_df[
(travel_df["통합테마명"] == selected_theme) &
(~travel_df["여행지"].isin(result_df["여행지"]))
].drop_duplicates(subset=["여행지"])
if not fallback.empty:
fill_count = min(3 - len(result_df), len(fallback))
fill = fallback.sample(n=fill_count, random_state=random.randint(1, 9999))
result_df = pd.concat([result_df, fill], ignore_index=True)
# 샘플 저장
st.session_state[sample_key] = result_df
# 2.1)첫 문장 출력
ui_name = theme_ui_map.get(selected_theme, (selected_theme,))[0]
opening_line_template = theme_opening_lines.get(ui_name)
opening_line = opening_line_template.format(len(result_df)) if opening_line_template else ""
message = (
"<br>".join([
f"{i+1}. <strong>{row.여행지}</strong> "
f"({row.여행나라}, {row.여행도시}) "
f"{getattr(row, '한줄설명', '설명이 없습니다')}"
for i, row in enumerate(result_df.itertuples())
])
)
if opening_line_template:
message_combined = f"{opening_line}<br><br>{message}"
with chat_container:
log_and_render(message_combined,
sender="bot",
chat_container=chat_container,
key=f"emotion_recommendation_{random.randint(1,999999)}"
)
# 2.2) 칩 버튼으로 추천지 중 선택받기
recommend_names = result_df["여행지"].tolist()
prev_choice = st.session_state.get(emotion_key, None)
choice = render_chip_buttons(
recommend_names + ["다른 여행지 보기 🔄"],
key_prefix="emotion_chip",
selected_value=prev_choice
)
# 2.3) 선택 결과 처리
if not choice or choice == prev_choice:
return
if choice == "다른 여행지 보기 🔄":
log_and_render("다른 여행지 보기 🔄",
sender="user",
chat_container=chat_container,
key=f"user_place_refresh_{random.randint(1,999999)}")
st.session_state.pop(sample_key, None)
st.rerun()
return
# 실제 선택한 여행지 처리
st.session_state[emotion_key] = choice
st.session_state[step_key] = "detail"
st.session_state.chat_log.append(("user", choice))
# 선택한 여행지를 prev 기록에 추가
match = result_df[result_df["여행지"] == choice]
if not match.empty:
prev.add(choice)
st.session_state[prev_key] = prev
# 샘플 폐기
st.session_state.pop(sample_key, None)
st.rerun()
return
# ────────────────── 3) 추천 종료 단계: 더 이상 추천할 여행지가 없을 때
elif st.session_state[step_key] == "recommend_place_end":
with chat_container:
# 3.1) 메시지 출력
log_and_render(
"⚠️ 더 이상 새로운 여행지가 없어요.<br>다시 질문하시겠어요?",
sender="bot",
chat_container=chat_container,
key="emotion_empty"
)
# 3.2) 재시작 여부 칩 버튼 출력
restart_done_key = "emotion_restart_done"
chip_ph = st.empty()
if not st.session_state.get(restart_done_key, False):
with chip_ph:
choice = render_chip_buttons(
["예 🔄", "아니오 ❌"],
key_prefix="emotion_restart"
)
else:
choice = None
# 3.3) 아직 아무것도 선택하지 않은 경우
if choice is None:
return
chip_ph.empty()
st.session_state[restart_done_key] = True
# 3.4) 사용자 선택값 출력
log_and_render(
choice,
sender="user",
chat_container=chat_container,
key=f"user_restart_choice_{choice}"
)
# 3.5) 사용자가 재추천을 원하는 경우
if choice == "예 🔄":
# 여행 추천 상태 초기화
for k in [emotion_key, prev_key, sample_key, restart_done_key]:
st.session_state.pop(k, None)
chip_ph.empty()
# 다음 추천 단계로 초기화
st.session_state["user_input_rendered"] = False
st.session_state["emotion_step"] = "restart"
log_and_render(
"다시 여행지를 추천해드릴게요!<br>요즘 떠오르는 여행이 있으신가요?",
sender="bot",
chat_container=chat_container,
key="emotion_restart_intro"
)
return
# 3.6) 사용자가 종료를 선택한 경우
else:
log_and_render("여행 추천을 종료할게요. 필요하실 때 언제든지 또 찾아주세요! ✈️",
sender="bot",
chat_container=chat_container,
key="emotion_exit")
st.stop()
return
# ────────────────── 4) 여행지 상세 단계
if st.session_state[step_key] == "detail":
chosen = st.session_state[emotion_key]
# city 이름 뽑아서 세션에 저장
row = travel_df[travel_df["여행지"] == chosen].iloc[0]
st.session_state["selected_city"] = row["여행도시"]
st.session_state["selected_place"] = chosen
log_and_render(chosen,
sender="user",
chat_container=chat_container,
key=f"user_place_{chosen}")
handle_selected_place(
chosen,
travel_df,
external_score_df,
festival_df,
weather_df,
chat_container=chat_container
)
st.session_state[step_key] = "companion"
st.rerun()
return
# ────────────────── 5) 동행·연령 받기 단계
elif st.session_state[step_key] == "companion":
with chat_container:
# 5.1) 안내 메시지 출력
log_and_render(
"함께 가는 분이나 연령대를 알려주시면 더 딱 맞는 상품을 골라드릴게요!<br>"
"1️⃣ 동행 여부 (혼자 / 친구 / 커플 / 가족 / 단체)<br>"
"2️⃣ 연령대 (20대 / 30대 / 40대 / 50대 / 60대 이상)",
sender="bot",
chat_container=chat_container,
key="ask_companion_age"
)
# 5.1.1) 동행 체크박스
st.markdown(
'<div style="font-size:14px; font-weight:600; margin:14px 0px 6px 0px;">👫 동행 선택</div>',
unsafe_allow_html=True
)
c_cols = st.columns(5)
comp_flags = {
"혼자": c_cols[0].checkbox("혼자"),
"친구": c_cols[1].checkbox("친구"),
"커플": c_cols[2].checkbox("커플"),
"가족": c_cols[3].checkbox("가족"),
"단체": c_cols[4].checkbox("단체"),
}
companions = [k for k, v in comp_flags.items() if v]
# 5.1.2) 연령 체크박스
st.markdown(
'<div style="font-size:14px; font-weight:600; margin:0px 0px 6px 0px;">🎂 연령 선택</div>',
unsafe_allow_html=True
)
a_cols = st.columns(5)
age_flags = {
"20대": a_cols[0].checkbox("20대"),
"30대": a_cols[1].checkbox("30대"),
"40대": a_cols[2].checkbox("40대"),
"50대": a_cols[3].checkbox("50대"),
"60대 이상": a_cols[4].checkbox("60대 이상"),
}
age_group = [k for k, v in age_flags.items() if v]
# 5.1.3) 확인 버튼
confirm = st.button(
"추천 받기",
key="btn_confirm_companion",
disabled=not (companions or age_group),
)
# 5.2) 메시지 출력
if confirm:
# 사용자 버블 출력
user_msg = " / ".join(companions + age_group)
log_and_render(
user_msg if user_msg else "선택 안 함",
sender="user",
chat_container=chat_container,
key=f"user_comp_age_{random.randint(1,999999)}"
)
# 세션 저장
st.session_state["companions"] = companions or None
st.session_state["age_group"] = age_group or None
# 다음 스텝
st.session_state[step_key] = "package"
st.rerun()
return
# ────────────────── 6) 동행·연령 필터링· 패키지 출력 단계
elif st.session_state[step_key] == "package":
# 패키지 버블을 이미 만들었으면 건너뜀
if st.session_state.get("package_rendered", False):
st.session_state[step_key] = "package_end"
return
companions = st.session_state.get("companions")
age_group = st.session_state.get("age_group")
city = st.session_state.get("selected_city")
place = st.session_state.get("selected_place")
filtered = filter_packages_by_companion_age(
package_df, companions, age_group, city=city, top_n=2
)
if filtered.empty:
log_and_render(
"⚠️ 아쉽지만 지금 조건에 맞는 패키지가 없어요.<br>"
"다른 조건으로 다시 찾아볼까요?",
sender="bot", chat_container=chat_container,
key="no_package"
)
st.session_state[step_key] = "companion"
st.rerun()
return
combo_msg = make_companion_age_message(companions, age_group)
header = f"{combo_msg}"
# 패키지 카드 출력
used_phrases = set()
theme_row = travel_df[travel_df["여행지"] == place]
raw_theme = theme_row["통합테마명"].iloc[0] if not theme_row.empty else None
selected_ui_theme = theme_ui_map.get(raw_theme, (raw_theme,))[0]
title_candidates = theme_title_phrases.get(selected_ui_theme, ["추천"])
sampled_titles = random.sample(title_candidates,
k=min(2, len(title_candidates)))
# 메시지 생성
pkg_msgs = [header]
for i, (_, row) in enumerate(filtered.iterrows(), 1):
desc, used_phrases = make_top2_description_custom(
row.to_dict(), used_phrases
)
tags = format_summary_tags_custom(row["요약정보"])
title_phrase = (sampled_titles[i-1] if i <= len(sampled_titles)
else random.choice(title_candidates))
title = f"{city} {title_phrase} 패키지"
url = row.URL
pkg_msgs.append(
f"{i}. <strong>{title}</strong><br>"
f"🅼 {desc}<br>{tags}<br>"
f'<a href="{url}" target="_blank" rel="noopener noreferrer" '
'style="text-decoration:none;font-weight:600;color:#009c75;">'
'💚 바로가기&nbsp;↗</a>'
)
# 메시지 출력
log_and_render(
"<br><br>".join(pkg_msgs),
sender="bot",
chat_container=chat_container,
key=f"pkg_bundle_{random.randint(1,999999)}"
)
# 세션 정리
st.session_state["package_rendered"] = True
st.session_state[step_key] = "package_end"
return
# ────────────────── 7) 종료 단계
elif st.session_state[step_key] == "package_end":
log_and_render("필요하실 때 언제든지 또 찾아주세요! ✈️",
sender="bot", chat_container=chat_container,
key="goodbye")
# ───────────────────────────────────── unknown 모드
def unknown_ui(country, city, chat_container, log_and_render):
"""unknown 모드(아직 DB에 없는 나라·도시일 때 안내) 전용 UI & 로직"""
# 안내 메시지
if city:
msg = (f"🔍 죄송해요. 해당 <strong>{city}</strong>의 여행지는 "
"아직 준비 중이에요.<br> 빠른 시일 안에 업데이트할게요!")
elif country:
msg = (f"🔍 죄송해요. 해당 <strong>{country}</strong>의 여행지는 "
"아직 준비 중이에요.<br> 빠른 시일 안에 업데이트할게요!")
else:
msg = "🔍 죄송해요. 해당 여행지는 아직 준비 중이에요."
with chat_container:
log_and_render(
f"{msg}",
sender="bot",
chat_container=chat_container,
key="unknown_dest"
)
# ───────────────────────────────────── 챗봇 호출
def main():
init_session()
chat_container = st.container()
# 🎛️ 말풍선/표시 옵션 (③, ④)
st.sidebar.subheader("⚙️ 대화 표시")
st.sidebar.selectbox("테마", ["피스타치오", "스카이블루", "크리미오트"], key="bubble_theme")
st.sidebar.toggle("타임스탬프 표시", value=False, key="show_time")
# ✅ 타자 효과 on/off 토글 (기본 ON)
st.sidebar.toggle("타자 효과", value=False, key="typewriter_on")
if "chat_log" in st.session_state and st.session_state.chat_log:
replay_log(chat_container)
# ───── greeting 메시지 출력
if not st.session_state.get("greeting_rendered", False):
greeting_message = (
"안녕하세요. <strong>모아(MoAi)</strong>입니다.🤖<br><br>"
"요즘 어떤 여행이 떠오르세요?<br>""모아가 딱 맞는 여행지를 찾아드릴게요."
)
log_and_render(
greeting_message,
sender="bot",
chat_container=chat_container,
key="greeting"
)
st.session_state["greeting_rendered"] = True
# ───── 사용자 입력 & 추천 시작
# 1) 사용자 입력
user_input = st.text_input(
"입력창", # 비어있지 않은 라벨(접근성 확보)
placeholder="ex)'요즘 힐링이 필요해요', '가족 여행 어디가 좋을까요?'",
key="user_input",
label_visibility="collapsed", # 화면에선 숨김
)
user_input_key = "last_user_input"
select_keys = ["intent_chip_selected", "region_chip_selected", "emotion_chip_selected", "theme_chip_selected"]
# 1-1) “진짜 새로 입력” 감지
prev = st.session_state.get(user_input_key, "")
if user_input and user_input != prev:
for k in select_keys:
st.session_state.pop(k, None)
st.session_state[user_input_key] = user_input
st.session_state["user_input_rendered"] = False
# step 초기화
st.session_state["region_step"] = "recommend"
st.rerun()
# 1-2) 사용자 메시지 한 번만 렌더링
if user_input and not st.session_state.get("user_input_rendered", False):
log_and_render(
user_input,
sender="user",
chat_container = chat_container,
key=f"user_input_{user_input}"
)
st.session_state["user_input_rendered"] = True
if user_input:
# 1) 저비용 단계: 위치/의도 먼저
country_filter, city_filter, loc_mode = detect_location_filter(user_input)
intent, intent_score = detect_intent(user_input)
# 사이드바에서 임계값을 쓸 수 있게 했다면, 없으면 0.70 기본
threshold = st.session_state.get("intent_threshold", 0.70)
# 2) 모드 결정: 지역 확정 → intent 확정 → unknown → (그 외) emotion
if loc_mode == "region":
mode = "region"
top_emotions, emotion_groups = [], []
elif intent_score >= threshold:
mode = "intent"
top_emotions, emotion_groups = [], []
elif loc_mode == "unknown":
mode = "unknown"
top_emotions, emotion_groups = [], []
else:
mode = "emotion"
# 3) 고비용 단계: 정말 필요할 때만 감성(BERT) 실행
# with st.spinner("감정 분석 중..."): # UX 원하시면 스피너 추가
top_emotions, emotion_groups = analyze_emotion(user_input)
# 4) 모드별 분기 (필요한 계산만 수행)
if mode == "region":
region_ui(
travel_df,
external_score_df,
festival_df,
weather_df,
package_df,
country_filter,
city_filter,
chat_container,
log_and_render
)
return
elif mode == "intent":
intent_ui(
travel_df,
external_score_df,
festival_df,
weather_df,
package_df,
country_filter,
city_filter,
chat_container,
intent,
log_and_render
)
return
elif mode == "unknown":
unknown_ui(
country_filter,
city_filter,
chat_container,
log_and_render
)
return
else: # emotion
# emotion 모드에서만 테마 추출 (불필요한 계산 방지)
candidate_themes = extract_themes(
emotion_groups,
intent,
force_mode=False # intent 확정 케이스가 아니라면 False
)
emotion_ui(
travel_df,
external_score_df,
festival_df,
weather_df,
package_df,
country_filter,
city_filter,
chat_container,
candidate_themes,
intent,
emotion_groups,
top_emotions,
log_and_render
)
if __name__ == "__main__":
main()
#cmd 입력-> cd "파일 위치 경로 복붙"
#ex(C:\Users\gayoung\Desktop\multi\0514 - project\06 - streamlit 테스트\test)
#cmd 입력 -> streamlit run app.py