chatbot-MoAi / app.py
code-slicer's picture
Update app.py
8fcd2fc verified
# -*- 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
import requests
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
)
# ──────────────────────────────── LLM ────────────────────────────────
OLLAMA_HOST = os.getenv("OLLAMA_HOST", "http://localhost:11434")
OLLAMA_MODEL = os.getenv("OLLAMA_MODEL", "gemma2:9b")
OLLAMA_TIMEOUT = int(os.getenv("OLLAMA_TIMEOUT", "300"))
KOREAN_SYSTEM_PROMPT = """당신은 ν•œκ΅­μ–΄ μ–΄μ‹œμŠ€ν„΄νŠΈμž…λ‹ˆλ‹€. 항상 ν•œκ΅­μ–΄λ‘œ λ‹΅ν•˜μ„Έμš”."""
STRUCTURED_EXTRACTION_SYSTEM = """\
You are a travel assistant that extracts structured fields from Korean user queries.
Return ONLY a valid JSON object:
{
"emotion": "happy|sad|stressed|excited|tired|none",
"intent": "beach|hiking|shopping|food|museum|relaxing|none",
"country_hint": "",
"city_hint": "",
"themes_hint": ["<0..3 words>"],
"notes": "<very short reasoning in Korean>"
}
If unknown, use "none" or "" and NEVER add extra text outside JSON.
"""
def to_llm_mode():
# 같은 λ Œλ” μ‚¬μ΄ν΄μ—μ„œ μ—¬λŸ¬ 번 ν˜ΈμΆœλ˜μ–΄λ„ 1회만 λ™μž‘ν•˜κ²Œ κ°€λ“œ
if not st.session_state.get("_llm_triggered"):
st.session_state["_llm_triggered"] = True
st.session_state["llm_mode"] = True
st.session_state["llm_intro_needed"] = True
st.rerun()
def _ensure_llm_state():
st.session_state.setdefault("llm_mode", False)
st.session_state.setdefault("llm_inline", False)
st.session_state.setdefault("llm_intro_needed", False)
st.session_state.setdefault("llm_msgs", [])
def show_llm_inline():
_ensure_llm_state()
st.session_state["llm_inline"] = True
st.session_state["llm_intro_needed"] = True
def _build_structured_user_prompt(user_text: str) -> str:
# λΆˆν•„μš”ν•œ λž˜ν•‘ 없이, λͺ¨λΈμ΄ JSON만 내도둝 κΉ”λ”νžˆ 전달
return user_text.strip()
def _ollama_healthcheck():
base = OLLAMA_HOST.rstrip("/")
# 1) μ„œλ²„ μ‚΄μ•„μžˆλŠ”μ§€
try:
r = requests.get(f"{base}/api/version", timeout=5)
r.raise_for_status()
except requests.RequestException as e:
st.error(f"❌ Ollama μ—°κ²° μ‹€νŒ¨: {e} (host={OLLAMA_HOST})")
return False
# 2) λͺ¨λΈ μ„€μΉ˜ μ—¬λΆ€
try:
tags = requests.get(f"{base}/api/tags", timeout=5).json()
names = [m.get("name") for m in tags.get("models", [])]
if OLLAMA_MODEL not in names:
st.warning(f"⚠️ λͺ¨λΈ λ―Έμ„€μΉ˜: `{OLLAMA_MODEL}`. μ„œλ²„μ—μ„œ `ollama pull {OLLAMA_MODEL}` μ‹€ν–‰ ν•„μš”.")
except requests.RequestException as e:
st.warning(f"λͺ¨λΈ λͺ©λ‘ 쑰회 μ‹€νŒ¨: {e}")
return True
def _call_ollama_chat(messages, model=OLLAMA_MODEL, temperature=0.8, top_p=0.9, top_k=40, repeat_penalty=1.1, system_prompt=None):
url = f"{OLLAMA_HOST}/api/chat"
_msgs = []
if system_prompt:
_msgs.append({"role": "system", "content": system_prompt})
_msgs.extend(messages)
payload = {
"model": model,
"messages": _msgs,
"options": {"temperature": temperature, "top_p": top_p, "top_k": top_k, "repeat_penalty": repeat_penalty},
"stream": False,
}
try:
r = requests.post(url, json=payload, timeout=OLLAMA_TIMEOUT)
r.raise_for_status()
return (r.json().get("message") or {}).get("content", "") or ""
except requests.Timeout:
st.error(f"⏱️ Ollama νƒ€μž„μ•„μ›ƒ({OLLAMA_TIMEOUT}s). host={OLLAMA_HOST}, model={model}")
except requests.ConnectionError as e:
st.error(f"πŸ”Œ μ—°κ²° μ‹€νŒ¨: {e} (host={OLLAMA_HOST})")
except requests.HTTPError as e:
try:
detail = r.text[:500]
except Exception:
detail = str(e)
st.error(f"HTTP {r.status_code}: {detail}")
except requests.RequestException as e:
st.error(f"μš”μ²­ 였λ₯˜: {e}")
return ""
def call_ollama_stream(messages, *, model: str = OLLAMA_MODEL,
temperature: float = 0.8, top_p: float = 0.9,
top_k: int = 40, repeat_penalty: float = 1.1,
num_predict: int = 200, num_ctx: int = 2048,
system_prompt: str | None = None):
"""
Ollama /api/chat 슀트리밍 μ œλ„ˆλ ˆμ΄ν„°.
Streamlitμ—μ„œλŠ” st.write_stream(...)으둜 λ°”λ‘œ μ“Έ 수 있음.
"""
url = f"{OLLAMA_HOST}/api/chat"
_msgs = []
if system_prompt:
_msgs.append({"role": "system", "content": system_prompt})
_msgs.extend(messages)
payload = {
"model": model,
"messages": _msgs,
"options": {
"temperature": temperature,
"top_p": top_p,
"top_k": top_k,
"repeat_penalty": repeat_penalty,
"num_predict": num_predict, # CPU + 9BλŠ” 128~256 ꢌμž₯
"num_ctx": num_ctx # 2048~4096
},
"stream": True, # βœ… 핡심
}
with requests.post(url, json=payload, stream=True, timeout=OLLAMA_TIMEOUT) as resp:
resp.raise_for_status()
for line in resp.iter_lines(decode_unicode=True):
if not line:
continue
data = json.loads(line)
if data.get("done"):
break
chunk = (data.get("message") or {}).get("content", "")
if chunk:
yield chunk
def _llm_structured_extract(user_text: str):
out = _call_ollama_chat(
[
{"role": "system", "content": STRUCTURED_EXTRACTION_SYSTEM},
{"role": "user", "content": _build_structured_user_prompt(user_text)}
],
system_prompt=None # μœ„μ—μ„œ system으둜 이미 λ„£μ—ˆμŒ
)
try:
data = json.loads(out)
except Exception:
data = {}
data.setdefault("emotion", "none")
data.setdefault("intent", "none")
data.setdefault("country_hint", "")
data.setdefault("city_hint", "")
data.setdefault("themes_hint", [])
data.setdefault("notes", "")
return data
# ──────────────────────────────── Streamlit용 LLM λͺ¨λ“œ UI ────────────────────────────────
def render_llm_followup(chat_container, inline=False):
_ensure_llm_state()
st.markdown("# λ– λ‚˜μ‹€ 여행지에 κ΄€ν•΄ κΆκΈˆν•˜μ‹  점이 μžˆμœΌμ‹ κ°€μš”?")
for m in st.session_state.get("llm_msgs", []):
with st.chat_message(m["role"]):
st.markdown(m["content"])
user_msg = st.chat_input("무엇이든 λ¬Όμ–΄λ³΄μ„Έμš” (μ’…λ£Œν•˜λ €λ©΄ 'μ’…λ£Œ' μž…λ ₯)", key="llm_query")
if not user_msg:
return
text = user_msg.strip()
# μ’…λ£Œ λͺ…λ Ή
if text in {"μ’…λ£Œ", "quit", "exit"}:
st.session_state["llm_inline"] = False
st.session_state["llm_mode"] = False
st.rerun()
return
# λŒ€ν™” μ €μž₯
st.session_state.setdefault("llm_msgs", [])
st.session_state["llm_msgs"].append({"role": "user", "content": text})
# βœ… 슀트리밍 호좜둜 λ³€κ²½
try:
with st.chat_message("assistant"):
# μ‹œμŠ€ν…œ ν”„λ‘¬ν”„νŠΈ + νžˆμŠ€ν† λ¦¬ λͺ¨λ‘ 보내기
msgs = st.session_state["llm_msgs"]
full_text = st.write_stream(
call_ollama_stream(
msgs,
model=OLLAMA_MODEL,
system_prompt=KOREAN_SYSTEM_PROMPT,
num_predict=200, # ν•„μš”μ‹œ 128~256 μ‘°μ •
num_ctx=2048
)
)
st.session_state["llm_msgs"].append({"role": "assistant", "content": full_text})
except requests.Timeout:
st.error(f"⏱️ Ollama νƒ€μž„μ•„μ›ƒ({OLLAMA_TIMEOUT}s). host={OLLAMA_HOST}, model={OLLAMA_MODEL}")
st.session_state["llm_msgs"].append({"role": "assistant", "content": "⚠️ νƒ€μž„μ•„μ›ƒμ΄ λ°œμƒν–ˆμŠ΅λ‹ˆλ‹€."})
except requests.RequestException as e:
st.error(f"μš”μ²­ 였λ₯˜: {e}")
st.session_state["llm_msgs"].append({"role": "assistant", "content": "⚠️ LLM 호좜 쀑 였λ₯˜κ°€ λ°œμƒν–ˆμŠ΅λ‹ˆλ‹€."})
st.rerun()
def render_llm_inline_if_open(chat_container):
"""llm_inline ν”Œλž˜κ·Έκ°€ 켜져 있으면 인라인 LLM νŒ¨λ„μ„ κ·Έλ¦½λ‹ˆλ‹€."""
_ensure_llm_state()
if st.session_state.get("llm_inline", False):
render_llm_followup(chat_container, inline=True)
# μ§€μ—° μ΄ˆκΈ°ν™”: 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")
st.markdown("""
<style>
/* 1) μ™Όμͺ½ μ‚¬μ΄λ“œλ°” 전체λ₯Ό 화면에 κ³ μ • */
aside[data-testid="stSidebar"], [data-testid="stSidebar"]{
position: fixed !important;
top: 0; left: 0;
height: 100vh !important;
z-index: 1000;
}
/* 2) μ‚¬μ΄λ“œλ°” μ•ˆμ˜ μ½˜ν…μΈ λ₯Ό '뢙박이'둜: λ‚΄λΆ€ 슀크둀 끔 */
aside[data-testid="stSidebar"] > div{
height: 100%;
overflow: hidden !important; /* ← λ‚΄μš©μ΄ 고정됨(슀크둀 μ•ˆ 됨) */
}
/* 3) 본문은 였λ₯Έμͺ½μœΌλ‘œ λ°€μ–΄ κ²ΉμΉ¨ λ°©μ§€ (340 + 24 μ—¬λ°±) */
[data-testid="stAppViewContainer"] > .main .block-container,
section.main > div.block-container,
div[data-testid="block-container"]{
margin-left: 364px !important;
}
</style>
""", unsafe_allow_html=True)
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,
)
# κ³ μ • 이미지 URL
#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"
# === λ°°κ²½ μ„€μ • UI (μˆ˜μ •λ¨) ===
st.sidebar.subheader("🎨 λ°°κ²½ μ„€μ •")
st.sidebar.toggle("λ°°κ²½ 이미지 μ‚¬μš©", key="bg_on", value=True)
# 1. 'λ°°κ²½ 이미지 μ‚¬μš©'이 ON일 λ•Œλ§Œ 이미지 κ΄€λ ¨ μ˜΅μ…˜ ν‘œμ‹œ
if st.session_state.bg_on:
with st.sidebar.expander("이미지 λ°°κ²½ μ˜΅μ…˜", expanded=True):
st.text_input("λ°°κ²½ 이미지 URL", key="bg_url", value="https://images.unsplash.com/photo-1506744038136-46273834b3fb")
st.slider("λ°°κ²½ 이미지 μ˜€λ²„λ ˆμ΄ (%)", 0, 100, 85, key="bg_overlay_pct")
# 2. 'λ°°κ²½ 이미지 μ‚¬μš©'이 OFF일 λ•Œλ§Œ 단색 κ΄€λ ¨ μ˜΅μ…˜ ν‘œμ‹œ
else:
with st.sidebar.expander("단색 λ°°κ²½ μ˜΅μ…˜", expanded=True):
# μΆ”μ²œ 색상 νŒ”λ ˆνŠΈλ₯Ό λ²„νŠΌμœΌλ‘œ κ΅¬ν˜„
palette = {
"Light Gray": "#F1F1F1",
"Mint": "#E3E8E3",
"Sky Blue": "#D9E1E2",
"Beige": "#F0F0EC"
}
selected_color_name = st.radio(
"μΆ”μ²œ 색상",
options=palette.keys(),
key="selected_color_name",
horizontal=True # λ²„νŠΌμ„ κ°€λ‘œλ‘œ λ°°μ—΄
)
#μ„ νƒλœ λΌλ””μ˜€ λ²„νŠΌμ˜ 색상 μ½”λ“œλ₯Ό color_picker의 κΈ°λ³Έκ°’μœΌλ‘œ μ‚¬μš©
st.color_picker(
"색상 직접 선택",
key="bg_color",
value=palette[selected_color_name]
)
def apply_background():
# 보호: κΈ°μ‘΄ ::before 배경이 있으면 끄기 (κ²ΉμΉ¨/λŠκΉ€ λ°©μ§€)
base_reset_css = """
<style>
.stApp::before, .block-container::before { content:none !important; }
/* μž…λ ₯λ°•μŠ€ μ•„λž˜ μ—¬λ°± */
div[data-testid="stTextInput"] { margin-bottom:18px !important; }
</style>
"""
st.markdown(base_reset_css, unsafe_allow_html=True)
if st.session_state.get("bg_on") and st.session_state.get("bg_url"):
url = st.session_state["bg_url"]
overlay_alpha = float(st.session_state.get("bg_overlay_pct", 15)) / 100.0
# βœ… 이미지 λ°°κ²½ (메인 컨텐츠 μ˜μ—­μ—λ§Œ κ³ μ • λ°°κ²½ 적용)
st.markdown(f"""
<style>
/* 상단·배경 투λͺ… 처리 */
header[data-testid="stHeader"],
main, section.main {{ background: transparent !important; }}
[data-testid="stAppViewContainer"] {{
background: url('{url}') center / cover no-repeat fixed;
position: relative;
z-index: 0;
}}
/* μ˜€λ²„λ ˆμ΄: 이미지 μœ„μ— 흰색 막을 μ–Ήμ–΄ 가독성 확보 */
[data-testid="stAppViewContainer"]::after {{
content: "";
position: absolute;
inset: 0;
background: rgba(255, 255, 255, {overlay_alpha});
z-index: -1;
pointer-events: none;
}}
/* 컨텐츠와 μ‚¬μ΄λ“œλ°”κ°€ 배경보닀 μœ„μ— μ˜€λ„λ‘ */
.block-container, [data-testid="stSidebar"] {{
position: relative;
z-index: 1;
}}
/* λͺ¨λ°”일은 fixed μ΄μŠˆκ°€ μžˆμ–΄ κ³ μ • ν•΄μ œ */
@media (max-width: 768px) {{
[data-testid="stAppViewContainer"] {{
background-attachment: initial;
}}
}}
</style>
""", unsafe_allow_html=True)
else:
# βœ… 단색 λ°°κ²½ (메인 컨텐츠 μ˜μ—­μ—λ§Œ 적용)
color = st.session_state.get("bg_color", "#F1F1F1")
st.markdown(f"""
<style>
[data-testid="stAppViewContainer"] {{
background-color: {color} !important;
}}
</style>
""", unsafe_allow_html=True)
# ν•¨μˆ˜ 호좜
apply_background()
# ── 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] = "recommend_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] == "recommend_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
)
if st.session_state.get("package_rendered", False):
st.session_state[step_key] = "package_end"
else:
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"):
st.session_state[step_key] = "package_end"
st.rerun()
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"
show_llm_inline()
# βœ… rerun 없이 같은 사이클에 인라인 LLM νŒ¨λ„μ„ λ°”λ‘œ ν‘œμ‹œ
render_llm_inline_if_open(chat_container)
return
# ────────────────── 7) μ’…λ£Œ 단계
elif st.session_state[step_key] == "package_end":
log_and_render("ν•„μš”ν•˜μ‹€ λ•Œ μ–Έμ œλ“ μ§€ 또 μ°Ύμ•„μ£Όμ„Έμš”! ✈️",
sender="bot", chat_container=chat_container,
key="goodbye")
show_llm_inline()
render_llm_inline_if_open(chat_container)
return
# ───────────────────────────────────── 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
)
if st.session_state.get("package_rendered", False):
st.session_state[step_key] = "package_end"
else:
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"):
st.session_state[step_key] = "package_end"
st.rerun()
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"
show_llm_inline() # ν”Œλž˜κ·Έλ§Œ ON (rerun μ—†μŒ)
# βœ… rerun 없이 같은 사이클에 인라인 LLM νŒ¨λ„μ„ λ°”λ‘œ ν‘œμ‹œ
render_llm_inline_if_open(chat_container)
return
# ────────────────── 7) μ’…λ£Œ 단계
elif st.session_state[step_key] == "package_end":
log_and_render("ν•„μš”ν•˜μ‹€ λ•Œ μ–Έμ œλ“ μ§€ 또 μ°Ύμ•„μ£Όμ„Έμš”! ✈️",
sender="bot", chat_container=chat_container,
key="goodbye")
show_llm_inline()
render_llm_inline_if_open(chat_container)
return
# ───────────────────────────────────── 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_places_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
)
if st.session_state.get("package_rendered", False):
st.session_state[step_key] = "package_end"
else:
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"):
st.session_state[step_key] = "package_end"
st.rerun()
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"
show_llm_inline() # ν”Œλž˜κ·Έλ§Œ ON (rerun μ—†μŒ)
# βœ… rerun 없이 같은 사이클에 인라인 LLM νŒ¨λ„μ„ λ°”λ‘œ ν‘œμ‹œ
render_llm_inline_if_open(chat_container)
return
# ────────────────── 7) μ’…λ£Œ 단계
elif st.session_state[step_key] == "package_end":
log_and_render("ν•„μš”ν•˜μ‹€ λ•Œ μ–Έμ œλ“ μ§€ 또 μ°Ύμ•„μ£Όμ„Έμš”! ✈️",
sender="bot", chat_container=chat_container,
key="goodbye")
show_llm_inline()
render_llm_inline_if_open(chat_container)
return
# ───────────────────────────────────── 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 _get_active_step_key():
# mode = st.session_state.get("mode", "unknown")
# mapping = {
# "region": "region_step",
# "intent": "intent_step",
# "emotion": "emotion_step",
# "theme_selection": "theme_step",
# "place_selection": "place_step",
# "user_info_input": "user_info_step",
# }
# # 맀핑에 μ—†μœΌλ©΄ 곡용 ν‚€λ‘œ
# return mapping.get(mode, "flow_step")
# ───────────────────────────────────── 챗봇 호좜
def main():
init_session()
chat_container = st.container()
if not _ollama_healthcheck():
st.stop()
# βœ… ν’€μŠ€ν¬λ¦°μΌ λ•Œλ§Œ μ‘°κΈ° 리턴
if st.session_state.get("llm_mode") and not st.session_state.get("llm_inline", False):
render_llm_followup(chat_container, inline=False)
return
# πŸŽ›οΈ 말풍선/ν‘œμ‹œ μ˜΅μ…˜ (β‘’, β‘£)
st.sidebar.subheader("βš™οΈ λŒ€ν™” ν‘œμ‹œ")
st.sidebar.selectbox("ν…Œλ§ˆ", ["ν”ΌμŠ€νƒ€μΉ˜μ˜€", "μŠ€μΉ΄μ΄λΈ”λ£¨", "크리미였트"], key="bubble_theme")
st.sidebar.toggle("νƒ€μž„μŠ€νƒ¬ν”„ ν‘œμ‹œ", value=False, key="show_time")
# with st.sidebar.expander("DEBUG steps", expanded=False):
# st.write("mode:", st.session_state.get("mode"))
# st.write("step_key:", cur_step_key)
# st.write("state:", st.session_state.get(cur_step_key))
# βœ… νƒ€μž 효과 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", # 화면에선 μˆ¨κΉ€
disabled=st.session_state.get("llm_inline", False)
)
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