Jongpal12 commited on
Commit
4331b21
Β·
verified Β·
1 Parent(s): ca4b63f

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +1764 -0
app.py CHANGED
@@ -45,9 +45,12 @@ os.environ.setdefault("HF_HUB_ENABLE_HF_TRANSFER", "1")
45
  from huggingface_hub import hf_hub_download
46
  import pandas as pd
47
  import streamlit as st
 
48
  from streamlit.components.v1 import html
49
  from css import render_message, render_chip_buttons, log_and_render, replay_log, _get_colors
50
 
 
 
51
  # ──────────────────────────────── Dataset Repo μ„€μ • ────────────────────────────────
52
  HF_DATASET_REPO = os.getenv("HF_DATASET_REPO", "emisdfde/moai-travel-data")
53
  HF_DATASET_REV = os.getenv("HF_DATASET_REV", "main")
@@ -72,13 +75,16 @@ def load_csv_smart(local_path: str,
72
  repo_id: str = HF_DATASET_REPO,
73
  repo_type: str = "dataset",
74
  revision: str = HF_DATASET_REV) -> pd.DataFrame:
 
75
  if hub_filename is None:
76
  hub_filename = os.path.basename(local_path)
 
77
  if os.path.exists(local_path):
78
  with open(local_path, "rb") as f:
79
  data = f.read()
80
  if not _is_pointer_bytes(data):
81
  return _read_csv_bytes(data)
 
82
  cached = hf_hub_download(repo_id=repo_id, filename=hub_filename,
83
  repo_type=repo_type, revision=revision)
84
  try:
@@ -111,3 +117,1761 @@ weather_df = load_csv_smart("weather.csv", "weather.csv")
111
  package_df = load_csv_smart("packages.csv", "packages.csv")
112
  master_df = load_csv_smart("countries_cities.csv", "countries_cities.csv")
113
  theme_title_phrases = load_json_smart("theme_title_phrases.json", "theme_title_phrases.json")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
45
  from huggingface_hub import hf_hub_download
46
  import pandas as pd
47
  import streamlit as st
48
+ import requests
49
  from streamlit.components.v1 import html
50
  from css import render_message, render_chip_buttons, log_and_render, replay_log, _get_colors
51
 
52
+ #st.success("πŸŽ‰ 앱이 μ„±κ³΅μ μœΌλ‘œ μ‹œμž‘λ˜μ—ˆμŠ΅λ‹ˆλ‹€! 라이브러리 μ„€μΉ˜ 성곡!")
53
+
54
  # ──────────────────────────────── Dataset Repo μ„€μ • ────────────────────────────────
55
  HF_DATASET_REPO = os.getenv("HF_DATASET_REPO", "emisdfde/moai-travel-data")
56
  HF_DATASET_REV = os.getenv("HF_DATASET_REV", "main")
 
75
  repo_id: str = HF_DATASET_REPO,
76
  repo_type: str = "dataset",
77
  revision: str = HF_DATASET_REV) -> pd.DataFrame:
78
+ # hub_filename μƒλž΅ μ‹œ 둜컬 파일λͺ… μ‚¬μš©
79
  if hub_filename is None:
80
  hub_filename = os.path.basename(local_path)
81
+ # 1) 둜컬 μš°μ„ 
82
  if os.path.exists(local_path):
83
  with open(local_path, "rb") as f:
84
  data = f.read()
85
  if not _is_pointer_bytes(data):
86
  return _read_csv_bytes(data)
87
+ # 2) ν—ˆλΈŒ λ‹€μš΄λ‘œλ“œ
88
  cached = hf_hub_download(repo_id=repo_id, filename=hub_filename,
89
  repo_type=repo_type, revision=revision)
90
  try:
 
117
  package_df = load_csv_smart("packages.csv", "packages.csv")
118
  master_df = load_csv_smart("countries_cities.csv", "countries_cities.csv")
119
  theme_title_phrases = load_json_smart("theme_title_phrases.json", "theme_title_phrases.json")
120
+
121
+ # ν•„μˆ˜ 컬럼 κ°€λ“œ
122
+ for col in ("μ—¬ν–‰λ‚˜λΌ", "μ—¬ν–‰λ„μ‹œ", "μ—¬ν–‰μ§€"):
123
+ if col not in travel_df.columns:
124
+ st.error(f"'travel_df'에 '{col}' 컬럼이 μ—†μŠ΅λ‹ˆλ‹€. μ‹€μ œ 컬럼: {travel_df.columns.tolist()}")
125
+ st.stop()
126
+
127
+ # ──────────────────────────────── chat_a import & μ΄ˆκΈ°ν™” ────────────────────────────────
128
+ from chat_a import (
129
+ init_datasets, # ⬅️ μƒˆλ‘œ μΆ”κ°€λœ μ§€μ—° μ΄ˆκΈ°ν™” ν•¨μˆ˜
130
+ analyze_emotion,
131
+ detect_intent,
132
+ extract_themes,
133
+ recommend_places_by_theme,
134
+ detect_location_filter,
135
+ generate_intro_message,
136
+ theme_ui_map,
137
+ ui_to_theme_map,
138
+ theme_opening_lines,
139
+ intent_opening_lines,
140
+ apply_weighted_score_filter,
141
+ get_highlight_message,
142
+ get_weather_message,
143
+ get_intent_intro_message,
144
+ recommend_packages,
145
+ handle_selected_place,
146
+ generate_region_intro,
147
+ parse_companion_and_age,
148
+ filter_packages_by_companion_age,
149
+ make_top2_description_custom,
150
+ format_summary_tags_custom,
151
+ make_companion_age_message
152
+ )
153
+
154
+ # ──────────────────────────────── LLM ────────────────────────────────
155
+ OLLAMA_HOST = os.getenv("OLLAMA_HOST", "http://localhost:11434")
156
+ OLLAMA_MODEL = os.getenv("OLLAMA_MODEL", "gemma2:9b")
157
+ OLLAMA_TIMEOUT = int(os.getenv("OLLAMA_TIMEOUT", "60"))
158
+
159
+ KOREAN_SYSTEM_PROMPT = """당신은 ν•œκ΅­μ–΄ μ–΄μ‹œμŠ€ν„΄νŠΈμž…λ‹ˆλ‹€. 항상 ν•œκ΅­μ–΄λ‘œ λ‹΅ν•˜μ„Έμš”."""
160
+
161
+ STRUCTURED_EXTRACTION_SYSTEM = """\
162
+ You are a travel assistant that extracts structured fields from Korean user queries.
163
+ Return ONLY a valid JSON object:
164
+ {
165
+ "emotion": "happy|sad|stressed|excited|tired|none",
166
+ "intent": "beach|hiking|shopping|food|museum|relaxing|none",
167
+ "country_hint": "",
168
+ "city_hint": "",
169
+ "themes_hint": ["<0..3 words>"],
170
+ "notes": "<very short reasoning in Korean>"
171
+ }
172
+ If unknown, use "none" or "" and NEVER add extra text outside JSON.
173
+ """
174
+ def to_llm_mode():
175
+ # 같은 λ Œλ” μ‚¬μ΄ν΄μ—μ„œ μ—¬λŸ¬ 번 ν˜ΈμΆœλ˜μ–΄λ„ 1회만 λ™μž‘ν•˜κ²Œ κ°€λ“œ
176
+ if not st.session_state.get("_llm_triggered"):
177
+ st.session_state["_llm_triggered"] = True
178
+ st.session_state["llm_mode"] = True
179
+ st.session_state["llm_intro_needed"] = True
180
+ st.rerun()
181
+
182
+ def _ensure_llm_state():
183
+ st.session_state.setdefault("llm_mode", False) # ν’€μŠ€ν¬λ¦° λͺ¨λ“œμš©(κΈ°μ‘΄)
184
+ st.session_state.setdefault("llm_inline", False) # βœ… 인라인 ν‘œμ‹œμš©
185
+ st.session_state.setdefault("llm_history", [])
186
+ st.session_state.setdefault("llm_intro_needed", False)
187
+ st.session_state.setdefault("llm_input", "")
188
+
189
+ def show_llm_inline():
190
+ _ensure_llm_state()
191
+ st.session_state["llm_inline"] = True
192
+ st.session_state["llm_intro_needed"] = True
193
+
194
+ def _build_structured_user_prompt(user_text: str) -> str:
195
+ # λΆˆν•„μš”ν•œ λž˜ν•‘ 없이, λͺ¨λΈμ΄ JSON만 내도둝 κΉ”λ”νžˆ 전달
196
+ return user_text.strip()
197
+
198
+ def _call_ollama_chat(
199
+ messages,
200
+ model=OLLAMA_MODEL,
201
+ temperature=0.8, top_p=0.9, top_k=40, repeat_penalty=1.1,
202
+ system_prompt=None
203
+ ):
204
+ url = f"{OLLAMA_HOST}/api/chat"
205
+ _msgs = []
206
+ if system_prompt:
207
+ _msgs.append({"role": "system", "content": system_prompt})
208
+ _msgs.extend(messages)
209
+
210
+ payload = {
211
+ "model": model,
212
+ "messages": _msgs,
213
+ "options": {
214
+ "temperature": temperature,
215
+ "top_p": top_p,
216
+ "top_k": top_k,
217
+ "repeat_penalty": repeat_penalty,
218
+ },
219
+ "stream": False,
220
+ }
221
+ try:
222
+ r = requests.post(url, json=payload, timeout=OLLAMA_TIMEOUT)
223
+ r.raise_for_status()
224
+ return (r.json().get("message") or {}).get("content", "") or ""
225
+ except requests.RequestException:
226
+ return ""
227
+
228
+ def _llm_structured_extract(user_text: str):
229
+ out = _call_ollama_chat(
230
+ [
231
+ {"role": "system", "content": STRUCTURED_EXTRACTION_SYSTEM},
232
+ {"role": "user", "content": _build_structured_user_prompt(user_text)}
233
+ ],
234
+ system_prompt=None # μœ„μ—μ„œ system으둜 이미 λ„£μ—ˆμŒ
235
+ )
236
+ try:
237
+ data = json.loads(out)
238
+ except Exception:
239
+ data = {}
240
+ data.setdefault("emotion", "none")
241
+ data.setdefault("intent", "none")
242
+ data.setdefault("country_hint", "")
243
+ data.setdefault("city_hint", "")
244
+ data.setdefault("themes_hint", [])
245
+ data.setdefault("notes", "")
246
+ return data
247
+
248
+ # ──────────────────────────────── Streamlit용 LLM λͺ¨λ“œ UI ────────────────────────────────
249
+ def _ensure_llm_state():
250
+ st.session_state.setdefault("llm_mode", False)
251
+ st.session_state.setdefault("llm_history", []) # [{'role':'user'|'assistant', 'content': str}, ...]
252
+ st.session_state.setdefault("llm_intro_needed", False)
253
+ st.session_state.setdefault("llm_input", "")
254
+
255
+ def render_llm_followup(chat_container, inline=False):
256
+ _ensure_llm_state()
257
+ MAX_TURNS = 6
258
+
259
+ # ── 인라인: ꡬ뢄선 λ¨Όμ €, κ·Έ λ‹€μŒ μ•ˆλ‚΄ 버블, κ·Έ λ‹€μŒ μž…λ ₯μ°½ ──
260
+ if inline:
261
+ st.divider()
262
+ if st.session_state.pop("llm_intro_needed", False):
263
+ log_and_render(
264
+ "🧠 μ΄μ œλΆ€ν„°λŠ” 자유 질문 λͺ¨λ“œμ˜ˆμš”. μ—¬ν–‰ 외에도 뭐든 ν•œκ΅­μ–΄λ‘œ λ¬Όμ–΄λ³΄μ„Έμš”!",
265
+ sender="bot",
266
+ chat_container=chat_container,
267
+ key=f"llm_intro_{random.randint(1,999999)}"
268
+ )
269
+ st.markdown("#### πŸ€– LLM 질문")
270
+ else:
271
+ # ν’€μŠ€ν¬λ¦° λͺ¨λ“œμ—μ„œλŠ” μ•ˆλ‚΄ 버블 λ¨Όμ €
272
+ if st.session_state.pop("llm_intro_needed", False):
273
+ log_and_render(
274
+ "🧠 μ΄μ œλΆ€ν„°λŠ” 자유 질문 λͺ¨λ“œμ˜ˆμš”. μ—¬ν–‰ 외에도 뭐든 ν•œκ΅­μ–΄λ‘œ λ¬Όμ–΄λ³΄μ„Έμš”!",
275
+ sender="bot",
276
+ chat_container=chat_container,
277
+ key=f"llm_intro_{random.randint(1,999999)}"
278
+ )
279
+ st.markdown("### πŸ€– LLM 질문")
280
+
281
+ # μž…λ ₯μ°½ (항상 μ—¬κΈ°μ„œλ§Œ λ…ΈμΆœ)
282
+ q = st.text_input(
283
+ "LLM 질문",
284
+ placeholder="무엇이든 λ¬Όμ–΄λ³΄μ„Έμš” (μ’…λ£Œν•˜λ €λ©΄ 'μ’…λ£Œ' μž…λ ₯)",
285
+ key="llm_input"
286
+ )
287
+
288
+ if q:
289
+ if q.strip() in ("μ’…λ£Œ", "quit", "exit"):
290
+ if inline:
291
+ # βœ… 인라인 μ’…λ£Œ(버블은 μœ μ§€, μƒˆλ‘œκ³ μΉ¨ μ—†μŒ)
292
+ st.session_state["llm_inline"] = False
293
+ st.session_state["llm_input"] = ""
294
+ log_and_render(
295
+ "LLM λͺ¨λ“œλ₯Ό μ’…λ£Œν• κ²Œμš”. ν•„μš”ν•˜μ‹€ λ•Œ λ‹€μ‹œ μ§ˆλ¬Έν•΄ μ£Όμ„Έμš”! ✨",
296
+ sender="bot", chat_container=chat_container,
297
+ key=f"llm_end_inline_{random.randint(1,999999)}"
298
+ )
299
+ return
300
+ else:
301
+ # ν’€μŠ€ν¬λ¦° μ’…λ£Œ
302
+ st.session_state["llm_mode"] = False
303
+ st.session_state["llm_input"] = ""
304
+ log_and_render(
305
+ "LLM λͺ¨λ“œλ₯Ό μ’…λ£Œν• κ²Œμš”. ν•„μš”ν•˜μ‹€ λ•Œ λ‹€μ‹œ μ§ˆλ¬Έν•΄ μ£Όμ„Έμš”! ✨",
306
+ sender="bot", chat_container=chat_container,
307
+ key=f"llm_end_full_{random.randint(1,999999)}"
308
+ )
309
+ st.rerun()
310
+ return
311
+
312
+ # 일반 질의 처리
313
+ log_and_render(q, sender="user", chat_container=chat_container,
314
+ key=f"llm_user_{random.randint(1,999999)}")
315
+ st.session_state.llm_history.append({"role": "user", "content": q})
316
+
317
+ msgs = st.session_state.llm_history[-(MAX_TURNS-1):]
318
+ a = _call_ollama_chat(
319
+ messages=msgs,
320
+ system_prompt=KOREAN_SYSTEM_PROMPT,
321
+ temperature=0.8, top_p=0.9, top_k=40, repeat_penalty=1.1
322
+ )
323
+ if not a:
324
+ log_and_render("⚠️ LLM 응닡을 λ°›μ§€ λͺ»ν–ˆμŠ΅λ‹ˆλ‹€. Ollama μ„œλ²„λ₯Ό 확인해 μ£Όμ„Έμš”.",
325
+ sender="bot", chat_container=chat_container,
326
+ key=f"llm_err_{random.randint(1,999999)}")
327
+ else:
328
+ log_and_render(a, sender="bot", chat_container=chat_container,
329
+ key=f"llm_bot_{random.randint(1,999999)}")
330
+ st.session_state.llm_history.append({"role": "assistant", "content": a})
331
+ st.session_state["llm_input"] = ""
332
+
333
+ # ν•˜λ‹¨ λ²„νŠΌ: 인라인은 'LLM νŒ¨λ„ μ’…λ£Œ'만, ν’€μŠ€ν¬λ¦°μ€ 'LLM λͺ¨λ“œ μ’…λ£Œ'만
334
+ if inline:
335
+ if st.button("πŸ”š LLM λͺ¨λ“œ μ’…λ£Œ", key="llm_close_inline"):
336
+ st.session_state["llm_inline"] = False
337
+ else:
338
+ if st.button("πŸ”š LLM λͺ¨λ“œ μ’…λ£Œ", key="llm_close_full"):
339
+ st.session_state["llm_mode"] = False
340
+ st.rerun()
341
+
342
+
343
+ # μ§€μ—° μ΄ˆκΈ°ν™”: import μ‹œμ μ—λŠ” 데이터 μ ‘κ·Ό κΈˆμ§€, μ—¬κΈ°μ„œ ν•œ 번만 μ£Όμž…
344
+ init_datasets(
345
+ travel_df=travel_df,
346
+ festival_df=festival_df,
347
+ external_score_df=external_score_df,
348
+ weather_df=weather_df,
349
+ package_df=package_df,
350
+ master_df=master_df,
351
+ theme_title_phrases=theme_title_phrases,
352
+ )
353
+ # ───────────────────────────────────── streamlit용 ν•¨μˆ˜
354
+ def init_session():
355
+ if "chat_log" not in st.session_state:
356
+ st.session_state.chat_log = []
357
+ if "mode" not in st.session_state:
358
+ st.session_state.mode = None
359
+ if "user_input" not in st.session_state:
360
+ st.session_state.user_input = ""
361
+ if "selected_theme" not in st.session_state:
362
+ st.session_state.selected_theme = None
363
+
364
+ def make_key(row) -> tuple[str, str]:
365
+ """prev 에 λ„£κ³  κΊΌλ‚Ό λ•Œ μ“°λŠ” κ³ μœ ν‚€(μ—¬ν–‰μ§€, μ—¬ν–‰λ„μ‹œ)"""
366
+ return (row["μ—¬ν–‰μ§€"], row["μ—¬ν–‰λ„μ‹œ"])
367
+
368
+ # ───────────────────────────────────── streamlit μ˜μ—­ μ„ μ–Έ
369
+ st.set_page_config(page_title="여행은 λͺ¨λ‘νˆ¬μ–΄ : λͺ¨μ•„(MoAi)", layout="centered")
370
+ accent = _get_colors().get("accent", "#0B8A5A")
371
+ st.markdown(
372
+ f"""
373
+ <h3 style="color:{accent}; font-weight:1000; margin:0.25rem 0 1rem;">
374
+ πŸ…Ό 여행은 λͺ¨λ‘νˆ¬μ–΄, μΆ”μ²œμ€ λͺ¨μ•„(MoAi)
375
+ </h3>
376
+ """,
377
+ unsafe_allow_html=True,
378
+ )
379
+
380
+ # κ³ μ • 이미지 URL
381
+ #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"
382
+
383
+ # === λ°°κ²½ μ„€μ • UI (μˆ˜μ •λ¨) ===
384
+ st.sidebar.subheader("🎨 λ°°κ²½ μ„€μ •")
385
+ st.sidebar.toggle("λ°°κ²½ 이미지 μ‚¬μš©", key="bg_on", value=True)
386
+
387
+ # 1. 'λ°°κ²½ 이미지 μ‚¬μš©'이 ON일 λ•Œλ§Œ 이미지 κ΄€λ ¨ μ˜΅μ…˜ ν‘œμ‹œ
388
+ if st.session_state.bg_on:
389
+ with st.sidebar.expander("이미지 λ°°κ²½ μ˜΅μ…˜", expanded=True):
390
+ st.text_input("λ°°κ²½ 이미지 URL", key="bg_url", value="https://images.unsplash.com/photo-1506744038136-46273834b3fb")
391
+ st.slider("λ°°κ²½ 이미지 μ˜€λ²„λ ˆμ΄ (%)", 0, 100, 85, key="bg_overlay_pct")
392
+ # 2. 'λ°°κ²½ 이미지 μ‚¬μš©'이 OFF일 λ•Œλ§Œ 단색 κ΄€λ ¨ μ˜΅μ…˜ ν‘œμ‹œ
393
+ else:
394
+ with st.sidebar.expander("단색 λ°°κ²½ μ˜΅μ…˜", expanded=True):
395
+ # μΆ”μ²œ 색상 νŒ”λ ˆνŠΈλ₯Ό λ²„νŠΌμœΌλ‘œ κ΅¬ν˜„
396
+ palette = {
397
+ "Light Gray": "#F1F1F1",
398
+ "Mint": "#E3E8E3",
399
+ "Sky Blue": "#D9E1E2",
400
+ "Beige": "#F0F0EC"
401
+ }
402
+ selected_color_name = st.radio(
403
+ "μΆ”μ²œ 색상",
404
+ options=palette.keys(),
405
+ key="selected_color_name",
406
+ horizontal=True # λ²„νŠΌμ„ κ°€λ‘œλ‘œ λ°°μ—΄
407
+ )
408
+
409
+ #μ„ νƒλœ λΌλ””μ˜€ λ²„νŠΌμ˜ 색상 μ½”λ“œλ₯Ό color_picker의 κΈ°λ³Έκ°’μœΌλ‘œ μ‚¬μš©
410
+ st.color_picker(
411
+ "색상 직접 선택",
412
+ key="bg_color",
413
+ value=palette[selected_color_name]
414
+ )
415
+
416
+
417
+ def apply_background():
418
+ # 보호: κΈ°μ‘΄ ::before 배경이 있으면 끄기 (κ²ΉμΉ¨/λŠκΉ€ λ°©μ§€)
419
+ base_reset_css = """
420
+ <style>
421
+ .stApp::before, .block-container::before { content:none !important; }
422
+ /* μž…λ ₯λ°•μŠ€ μ•„λž˜ μ—¬λ°± */
423
+ div[data-testid="stTextInput"] { margin-bottom:18px !important; }
424
+ </style>
425
+ """
426
+ st.markdown(base_reset_css, unsafe_allow_html=True)
427
+
428
+ if st.session_state.get("bg_on") and st.session_state.get("bg_url"):
429
+ url = st.session_state["bg_url"]
430
+ overlay_alpha = float(st.session_state.get("bg_overlay_pct", 15)) / 100.0
431
+
432
+ # βœ… 이미지 λ°°κ²½ (메인 컨텐츠 μ˜μ—­μ—λ§Œ κ³ μ • λ°°κ²½ 적용)
433
+ st.markdown(f"""
434
+ <style>
435
+ /* 상단·배경 투λͺ… 처리 */
436
+ header[data-testid="stHeader"],
437
+ main, section.main {{ background: transparent !important; }}
438
+
439
+ [data-testid="stAppViewContainer"] {{
440
+ background: url('{url}') center / cover no-repeat fixed;
441
+ position: relative;
442
+ z-index: 0;
443
+ }}
444
+
445
+ /* μ˜€λ²„λ ˆμ΄: 이미지 μœ„μ— 흰색 막을 μ–Ήμ–΄ 가독성 ν™•οΏ½οΏ½ */
446
+ [data-testid="stAppViewContainer"]::after {{
447
+ content: "";
448
+ position: absolute;
449
+ inset: 0;
450
+ background: rgba(255, 255, 255, {overlay_alpha});
451
+ z-index: -1;
452
+ pointer-events: none;
453
+ }}
454
+
455
+ /* 컨텐츠와 μ‚¬μ΄λ“œλ°”κ°€ 배경보닀 μœ„μ— μ˜€λ„λ‘ */
456
+ .block-container, [data-testid="stSidebar"] {{
457
+ position: relative;
458
+ z-index: 1;
459
+ }}
460
+
461
+ /* λͺ¨λ°”일은 fixed μ΄μŠˆκ°€ μžˆμ–΄ κ³ μ • ν•΄μ œ */
462
+ @media (max-width: 768px) {{
463
+ [data-testid="stAppViewContainer"] {{
464
+ background-attachment: initial;
465
+ }}
466
+ }}
467
+ </style>
468
+ """, unsafe_allow_html=True)
469
+
470
+ else:
471
+ # βœ… 단색 λ°°κ²½ (메인 컨텐츠 μ˜μ—­μ—λ§Œ 적용)
472
+ color = st.session_state.get("bg_color", "#F1F1F1")
473
+ st.markdown(f"""
474
+ <style>
475
+ [data-testid="stAppViewContainer"] {{
476
+ background-color: {color} !important;
477
+ }}
478
+ </style>
479
+ """, unsafe_allow_html=True)
480
+
481
+ # ν•¨μˆ˜ 호좜
482
+ apply_background()
483
+
484
+
485
+
486
+ # ── P κΈ€κΌ΄ 크기 14 px ───────────────────────────────────
487
+ st.markdown("""
488
+ <style>
489
+ /* κΈ°λ³Έ p νƒœκ·Έ κΈ€κΌ΄ 크기 */
490
+ html, body, p {
491
+ font-size: 14px !important; /* ← 14 px κ³ μ • */
492
+ line-height: 1.5; /* (선택) 가독성을 μœ„ν•œ 쀄간격 */
493
+ }
494
+
495
+ /* Streamlit κΈ°λ³Έ λ§ˆμ§„ 제거둜 λΆˆν•„μš”ν•œ μ—¬λ°± λ°©μ§€ (선택) */
496
+ p {
497
+ margin-top: 0;
498
+ margin-bottom: 0.5rem;
499
+ }
500
+ </style>
501
+ """, unsafe_allow_html=True)
502
+
503
+ # ───────────────────────────────────── region mode
504
+ def region_ui(travel_df, external_score_df, festival_df, weather_df, package_df,
505
+ country_filter, city_filter, chat_container, log_and_render):
506
+ """region λͺ¨λ“œ(νŠΉμ • λ‚˜λΌ, λ„μ‹œλ₯Ό 직접 μ–ΈκΈ‰ν–ˆμ„ 경우) μ „μš© UI & 둜직"""
507
+
508
+ # ────────────────── μ„Έμ…˜ ν‚€ μ •μ˜
509
+ region_key = "region_chip_selected"
510
+ prev_key = "region_prev_recommended"
511
+ step_key = "region_step"
512
+ sample_key = "region_sample_df"
513
+
514
+ # ────────────────── 0) μ΄ˆκΈ°ν™”
515
+ if step_key not in st.session_state:
516
+ st.session_state[step_key] = "recommend"
517
+ st.session_state[prev_key] = set()
518
+ st.session_state.pop(sample_key, None)
519
+
520
+
521
+ # ────────────────── 1) restart μƒνƒœλ©΄ 인트둜만 좜λ ₯ν•˜κ³  μ’…λ£Œ
522
+ if st.session_state[step_key] == "restart":
523
+ log_and_render(
524
+ "λ‹€μ‹œ μ—¬ν–‰μ§€λ₯Ό μΆ”μ²œν•΄λ“œλ¦΄κ²Œμš”!<br>μš”μ¦˜ λ– μ˜€λ₯΄λŠ” 여행이 μžˆμœΌμ‹ κ°€μš”?",
525
+ sender="bot",
526
+ chat_container=chat_container,
527
+ key="region_restart_intro"
528
+ )
529
+ return
530
+
531
+ # ────────────────── 2) μΆ”μ²œ 단계
532
+ if st.session_state[step_key] == "recommend":
533
+
534
+ # 2.1) μΆ”μ²œ 문ꡬ 좜λ ₯ (λ„μ‹œ λ˜λŠ” κ΅­κ°€ κΈ°μ€€)
535
+ city_exists = bool(city_filter) and city_filter in travel_df["μ—¬ν–‰λ„μ‹œ"].values
536
+ country_exists = bool(country_filter) and country_filter in travel_df["μ—¬ν–‰λ‚˜λΌ"].values
537
+
538
+ # μ‘΄μž¬ν•˜μ§€ μ•ŠλŠ” λ„μ‹œμΈ 경우
539
+ if city_filter and not city_exists:
540
+ intro = generate_region_intro('', country_filter)
541
+ log_and_render(
542
+ f"μ£„μ†‘ν•΄μš”. {city_filter}의 μ—¬ν–‰μ§€λŠ” 아직 λ―Έμ •μ΄μ—μš”.<br>ν•˜μ§€λ§Œ, {intro}",
543
+ sender="bot",
544
+ chat_container=chat_container,
545
+ key="region_intro_invalid"
546
+ )
547
+ else:
548
+ # 정상적인 λ„μ‹œ/ꡭ가일 경우
549
+ intro = generate_region_intro(city_filter, country_filter)
550
+ log_and_render(intro,
551
+ sender="bot",
552
+ chat_container=chat_container,
553
+ key="region_intro")
554
+
555
+ # 2.2) μ—¬ν–‰μ§€ 후보 λͺ©λ‘ 필터링
556
+ df = travel_df.drop_duplicates(subset=["μ—¬ν–‰μ§€"])
557
+ if city_exists:
558
+ df = df[df["μ—¬ν–‰λ„μ‹œ"].str.contains(city_filter, na=False)]
559
+ elif country_exists:
560
+ df = df[df["μ—¬ν–‰λ‚˜λΌ"].str.contains(country_filter, na=False)]
561
+
562
+ # 2.3) 이전 μΆ”μ²œ λͺ©λ‘κ³Ό κ²ΉμΉ˜μ§€ μ•ŠλŠ” μ—¬ν–‰μ§€λ§Œ 남김
563
+ prev = st.session_state.setdefault(prev_key, set())
564
+ remaining = df[~df.apply(lambda r: make_key(r) in prev, axis=1)]
565
+
566
+ # μΆ”μ²œ κ°€λŠ₯ν•œ μ—¬ν–‰μ§€κ°€ μ—†λ‹€λ©΄ μ’…λ£Œ λ‹¨κ³„λ‘œ μ „ν™˜
567
+ if remaining.empty and sample_key not in st.session_state:
568
+ st.session_state[step_key] = "recommand_end"
569
+ st.rerun()
570
+ return
571
+
572
+
573
+ # 2.4) μƒ˜ν”Œλ§ (이전 μƒ˜ν”Œμ΄ μ—†κ±°λ‚˜ λΉ„μ–΄ 있으면 μƒˆλ‘œ μΆ”μΆœ)
574
+ if sample_key not in st.session_state or st.session_state[sample_key].empty:
575
+ sampled = remaining.sample(
576
+ n=min(3, len(remaining)), #μ΅œλŒ€ 3개
577
+ random_state=random.randint(1, 9999)
578
+ )
579
+ st.session_state[sample_key] = sampled
580
+
581
+ # tuple ν˜•νƒœλ‘œ ν•œκΊΌλ²ˆμ— μΆ”κ°€
582
+ prev.update([make_key(r) for _, r in sampled.iterrows()])
583
+ st.session_state[prev_key] = prev
584
+ else:
585
+ sampled = st.session_state[sample_key]
586
+
587
+ loc_df = st.session_state[sample_key]
588
+
589
+ # 2.5) μΆ”μ²œ 리슀트 좜λ ₯ & μΉ© UI
590
+ message = (
591
+ "πŸ“Œ μΆ”μ²œ μ—¬ν–‰μ§€ λͺ©λ‘<br>κ°€μž₯ κ°€κ³  싢은 곳을 κ³¨λΌμ£Όμ„Έμš”!<br><br>" +
592
+ "<br>".join([
593
+ f"{i+1}. <strong>{row.μ—¬ν–‰μ§€}</strong> "
594
+ f"({row.μ—¬ν–‰λ‚˜λΌ}, {row.μ—¬ν–‰λ„μ‹œ}) "
595
+ f"{getattr(row, 'ν•œμ€„μ„€λͺ…', 'μ„€λͺ…이 μ—†μŠ΅λ‹ˆλ‹€')}"
596
+ for i, row in enumerate(loc_df.itertuples())
597
+ ])
598
+ )
599
+ with chat_container:
600
+ log_and_render(message,
601
+ sender="bot",
602
+ chat_container=chat_container,
603
+ key=f"region_recommendation_{random.randint(1,999999)}"
604
+ )
605
+ # μΉ© λ²„νŠΌμœΌλ‘œ μΆ”μ²œμ§€ 쀑 선택받기
606
+ prev_choice = st.session_state.get(region_key, None)
607
+ choice = render_chip_buttons(
608
+ loc_df["μ—¬ν–‰μ§€"].tolist() + ["λ‹€λ₯Έ μ—¬ν–‰μ§€ 보기 πŸ”„"],
609
+ key_prefix="region_chip",
610
+ selected_value=prev_choice
611
+ )
612
+
613
+ # 2.7) 선택 κ²°κ³Ό 처리
614
+ if not choice or choice == prev_choice:
615
+ return
616
+
617
+ if choice == "λ‹€λ₯Έ μ—¬ν–‰μ§€ 보기 πŸ”„":
618
+ log_and_render("λ‹€λ₯Έ μ—¬ν–‰μ§€ 보기 πŸ”„",
619
+ sender="user",
620
+ chat_container=chat_container,
621
+ key=f"user_place_refresh_{random.randint(1,999999)}")
622
+
623
+ st.session_state.pop(sample_key, None)
624
+ st.rerun()
625
+ return
626
+
627
+ # 2.8) μ—¬ν–‰μ§€ 선택 μ™„λ£Œ
628
+ st.session_state[region_key] = choice
629
+ st.session_state[step_key] = "detail"
630
+ st.session_state.chat_log.append(("user", choice))
631
+
632
+
633
+ # μ‹€μ œλ‘œ μ„ νƒλœ μ—¬ν–‰μ§€λ§Œ prev에 기둝
634
+ match = sampled[sampled["μ—¬ν–‰μ§€"] == choice]
635
+ if not match.empty:
636
+ prev.add(make_key(match.iloc[0]))
637
+ st.session_state[prev_key] = prev
638
+
639
+ # μƒ˜ν”Œ 폐기
640
+ st.session_state.pop(sample_key, None)
641
+ st.rerun()
642
+ return
643
+
644
+ # ────────────────── 3) μΆ”μ²œ μ’…λ£Œ 단계: 더 이상 μΆ”μ²œν•  μ—¬ν–‰μ§€κ°€ 없을 λ•Œ
645
+ elif st.session_state[step_key] == "recommand_end":
646
+ with chat_container:
647
+ # 3.1) λ©”μ‹œμ§€ 좜λ ₯
648
+ log_and_render(
649
+ "⚠️ 더 이상 μƒˆλ‘œμš΄ μ—¬ν–‰μ§€κ°€ μ—†μ–΄μš”.<br>λ‹€μ‹œ μ§ˆλ¬Έν•˜μ‹œκ² μ–΄μš”?",
650
+ sender="bot",
651
+ chat_container=chat_container,
652
+ key="region_empty"
653
+ )
654
+ # 3.2) μž¬μ‹œμž‘ μ—¬λΆ€ μΉ© λ²„νŠΌ 좜λ ₯
655
+ restart_done_key = "region_restart_done"
656
+ chip_ph = st.empty()
657
+
658
+ if not st.session_state.get(restart_done_key, False):
659
+ with chip_ph:
660
+ choice = render_chip_buttons(
661
+ ["예 πŸ”„", "μ•„λ‹ˆμ˜€ ❌"],
662
+ key_prefix="region_restart"
663
+ )
664
+ else:
665
+ choice = None
666
+
667
+ # 3.3) 아직 아무것도 μ„ νƒν•˜μ§€ μ•Šμ€ 경우
668
+ if choice is None:
669
+ return
670
+
671
+ chip_ph.empty()
672
+ st.session_state[restart_done_key] = True
673
+
674
+ # 3.4) μ‚¬μš©μž 선택값 좜λ ₯
675
+ log_and_render(
676
+ choice,
677
+ sender="user",
678
+ chat_container=chat_container,
679
+ key=f"user_restart_choice_{choice}"
680
+ )
681
+
682
+ # 3.5) μ‚¬μš©μžκ°€ μž¬μΆ”μ²œμ„ μ›ν•˜λŠ” 경우
683
+ if choice == "예 πŸ”„":
684
+ # μ—¬ν–‰ μΆ”μ²œ μƒνƒœ μ΄ˆκΈ°ν™”
685
+ for k in [region_key, prev_key, sample_key, restart_done_key]:
686
+ st.session_state.pop(k, None)
687
+ chip_ph.empty()
688
+
689
+ # λ‹€μŒ μΆ”μ²œ λ‹¨κ³„λ‘œ μ΄ˆκΈ°ν™”
690
+ st.session_state["user_input_rendered"] = False
691
+ st.session_state["region_step"] = "restart"
692
+
693
+ log_and_render(
694
+ "λ‹€μ‹œ μ—¬ν–‰μ§€λ₯Ό μΆ”μ²œν•΄λ“œλ¦΄κ²Œμš”!<br>μš”μ¦˜ λ– μ˜€λ₯΄λŠ” 여행이 μžˆμœΌμ‹ κ°€μš”?",
695
+ sender="bot",
696
+ chat_container=chat_container,
697
+ key="region_restart_intro"
698
+ )
699
+ return
700
+
701
+ # 3.6) μ‚¬μš©μžκ°€ μ’…λ£Œλ₯Ό μ„ νƒν•œ 경우
702
+ else:
703
+ log_and_render("μ—¬ν–‰ μΆ”μ²œμ„ μ’…λ£Œν• κ²Œμš”. ν•„μš”ν•˜μ‹€ λ•Œ μ–Έμ œλ“ μ§€ 또 μ°Ύμ•„μ£Όμ„Έμš”! ✈️",
704
+ sender="bot",
705
+ chat_container=chat_container,
706
+ key="region_exit")
707
+ st.stop()
708
+ return
709
+
710
+
711
+ # ────────────────── 4) μ—¬ν–‰μ§€ 상세 단계
712
+ if st.session_state[step_key] == "detail":
713
+ chosen = st.session_state[region_key]
714
+ # city 이름 λ½‘μ•„μ„œ μ„Έμ…˜μ— μ €μž₯
715
+ row = travel_df[travel_df["μ—¬ν–‰μ§€"] == chosen].iloc[0]
716
+ st.session_state["selected_city"] = row["μ—¬ν–‰λ„μ‹œ"]
717
+ st.session_state["selected_place"] = chosen
718
+
719
+ log_and_render(chosen,
720
+ sender="user",
721
+ chat_container=chat_container,
722
+ key=f"user_place_{chosen}")
723
+ handle_selected_place(
724
+ chosen,
725
+ travel_df,
726
+ external_score_df,
727
+ festival_df,
728
+ weather_df,
729
+ chat_container=chat_container
730
+ )
731
+ st.session_state[step_key] = "companion"
732
+ st.rerun()
733
+ return
734
+
735
+
736
+ # ────────────────── 5) 동행·연령 λ°›κΈ° 단계
737
+ elif st.session_state[step_key] == "companion":
738
+ with chat_container:
739
+ # 5.1) μ•ˆλ‚΄ λ©”μ‹œμ§€ 좜λ ₯
740
+ log_and_render(
741
+ "ν•¨κ»˜ κ°€λŠ” λΆ„μ΄λ‚˜ μ—°λ ΉλŒ€λ₯Ό μ•Œλ €μ£Όμ‹œλ©΄ 더 λ”± λ§žλŠ” μƒν’ˆμ„ κ³¨λΌλ“œλ¦΄κ²Œμš”!<br>"
742
+ "1️⃣ 동행 μ—¬λΆ€ (혼자 / 친ꡬ / μ»€ν”Œ / κ°€μ‘± / 단체)<br>"
743
+ "2️⃣ μ—°λ ΉλŒ€ (20λŒ€ / 30λŒ€ / 40λŒ€ / 50λŒ€ / 60λŒ€ 이상)",
744
+ sender="bot",
745
+ chat_container=chat_container,
746
+ key="ask_companion_age"
747
+ )
748
+
749
+ # 5.1.1) 동행 μ²΄ν¬λ°•μŠ€
750
+ st.markdown(
751
+ '<div style="font-size:14px; font-weight:600; margin:14px 0px 6px 0px;">πŸ‘« 동행 선택</div>',
752
+ unsafe_allow_html=True
753
+ )
754
+ c_cols = st.columns(5)
755
+ comp_flags = {
756
+ "혼자": c_cols[0].checkbox("혼자"),
757
+ "친ꡬ": c_cols[1].checkbox("친ꡬ"),
758
+ "μ»€ν”Œ": c_cols[2].checkbox("μ»€ν”Œ"),
759
+ "κ°€μ‘±": c_cols[3].checkbox("κ°€μ‘±"),
760
+ "단체": c_cols[4].checkbox("단체"),
761
+ }
762
+ companions = [k for k, v in comp_flags.items() if v]
763
+
764
+ # 5.1.2) μ—°λ Ή μ²΄ν¬λ°•μŠ€
765
+ st.markdown(
766
+ '<div style="font-size:14px; font-weight:600; margin:0px 0px 6px 0px;">πŸŽ‚ μ—°λ Ή 선택</div>',
767
+ unsafe_allow_html=True
768
+ )
769
+ a_cols = st.columns(5)
770
+ age_flags = {
771
+ "20λŒ€": a_cols[0].checkbox("20λŒ€"),
772
+ "30λŒ€": a_cols[1].checkbox("30λŒ€"),
773
+ "40λŒ€": a_cols[2].checkbox("40λŒ€"),
774
+ "50λŒ€": a_cols[3].checkbox("50λŒ€"),
775
+ "60λŒ€ 이상": a_cols[4].checkbox("60λŒ€ 이상"),
776
+ }
777
+ age_group = [k for k, v in age_flags.items() if v]
778
+
779
+ # 5.1.3) 확인 λ²„νŠΌ
780
+ confirm = st.button(
781
+ "μΆ”μ²œ λ°›κΈ°",
782
+ key="btn_confirm_companion",
783
+ disabled=not (companions or age_group),
784
+ )
785
+
786
+ # 5.2) λ©”μ‹œμ§€ 좜λ ₯
787
+ if confirm:
788
+ # μ‚¬μš©μž 버블 좜λ ₯
789
+ user_msg = " / ".join(companions + age_group)
790
+ log_and_render(
791
+ user_msg if user_msg else "선택 μ•ˆ 함",
792
+ sender="user",
793
+ chat_container=chat_container,
794
+ key=f"user_comp_age_{random.randint(1,999999)}"
795
+ )
796
+
797
+ # μ„Έμ…˜ μ €μž₯
798
+ st.session_state["companions"] = companions or None
799
+ st.session_state["age_group"] = age_group or None
800
+
801
+ # λ‹€μŒ μŠ€ν…
802
+ st.session_state[step_key] = "package"
803
+ st.rerun()
804
+ return
805
+
806
+
807
+ # ────────────────── 6) 동행·연령 필터링· νŒ¨ν‚€μ§€ 좜λ ₯ 단계
808
+ elif st.session_state[step_key] == "package":
809
+
810
+ # νŒ¨ν‚€μ§€ 버블을 이미 λ§Œλ“€μ—ˆμœΌλ©΄ κ±΄λ„ˆλœ€
811
+ if st.session_state.get("package_rendered", False):
812
+ st.session_state[step_key] = "package_end"
813
+ return
814
+
815
+ companions = st.session_state.get("companions")
816
+ age_group = st.session_state.get("age_group")
817
+ city = st.session_state.get("selected_city")
818
+ place = st.session_state.get("selected_place")
819
+
820
+ filtered = filter_packages_by_companion_age(
821
+ package_df, companions, age_group, city=city, top_n=2
822
+ )
823
+
824
+ if filtered.empty:
825
+ log_and_render(
826
+ "⚠️ μ•„μ‰½μ§€λ§Œ μ§€κΈˆ 쑰건에 λ§žλŠ” νŒ¨ν‚€μ§€κ°€ μ—†μ–΄μš”.<br>"
827
+ "λ‹€λ₯Έ 쑰건으둜 λ‹€μ‹œ μ°Ύμ•„λ³ΌκΉŒμš”?",
828
+ sender="bot", chat_container=chat_container,
829
+ key="no_package"
830
+ )
831
+ st.session_state[step_key] = "companion" # λ‹€μ‹œ μž…λ ₯ λ‹¨κ³„λ‘œ
832
+ st.rerun()
833
+ return
834
+
835
+ combo_msg = make_companion_age_message(companions, age_group)
836
+ header = f"{combo_msg}"
837
+
838
+ # νŒ¨ν‚€μ§€ μΉ΄λ“œ 좜λ ₯
839
+ used_phrases = set()
840
+ theme_row = travel_df[travel_df["μ—¬ν–‰μ§€"] == place]
841
+ raw_theme = theme_row["ν†΅ν•©ν…Œλ§ˆλͺ…"].iloc[0] if not theme_row.empty else None
842
+ selected_ui_theme = theme_ui_map.get(raw_theme, (raw_theme,))[0]
843
+
844
+ title_candidates = theme_title_phrases.get(selected_ui_theme, ["μΆ”μ²œ"])
845
+ sampled_titles = random.sample(title_candidates,
846
+ k=min(2, len(title_candidates)))
847
+
848
+ # λ©”μ‹œμ§€ 생성
849
+ pkg_msgs = [header]
850
+
851
+ for i, (_, row) in enumerate(filtered.iterrows(), 1):
852
+ desc, used_phrases = make_top2_description_custom(
853
+ row.to_dict(), used_phrases
854
+ )
855
+ tags = format_summary_tags_custom(row["μš”μ•½μ •λ³΄"])
856
+ title_phrase = (sampled_titles[i-1] if i <= len(sampled_titles)
857
+ else random.choice(title_candidates))
858
+ title = f"{city} {title_phrase} νŒ¨ν‚€μ§€"
859
+ url = row.URL
860
+
861
+ pkg_msgs.append(
862
+ f"{i}. <strong>{title}</strong><br>"
863
+ f"πŸ…Ό {desc}<br>{tags}<br>"
864
+ f'<a href="{url}" target="_blank" rel="noopener noreferrer" '
865
+ 'style="text-decoration:none;font-weight:600;color:#009c75;">'
866
+ 'πŸ’š λ°”λ‘œκ°€κΈ°&nbsp;β†—</a>'
867
+ )
868
+ # λ©”μ‹œμ§€ 좜λ ₯
869
+ log_and_render(
870
+ "<br><br>".join(pkg_msgs),
871
+ sender="bot",
872
+ chat_container=chat_container,
873
+ key=f"pkg_bundle_{random.randint(1,999999)}"
874
+ )
875
+
876
+ # μ„Έμ…˜ 정리
877
+ st.session_state["package_rendered"] = True
878
+ st.session_state[step_key] = "package_end"
879
+ show_llm_inline() # ν”Œλž˜κ·Έλ§Œ ON (rerun μ—†μŒ)
880
+ render_llm_followup(chat_container, inline=True) # πŸ‘ˆ 같은 μ‚¬μ΄ν΄μ—μ„œ λ°”λ‘œ μ•„λž˜μ— LLM λ°•μŠ€ 좜λ ₯
881
+ return
882
+
883
+ # ────────────────── 7) μ’…λ£Œ 단계
884
+ elif st.session_state[step_key] == "package_end":
885
+ log_and_render("ν•„μš”ν•˜μ‹€ λ•Œ μ–Έμ œλ“ μ§€ 또 μ°Ύμ•„μ£Όμ„Έμš”! ✈️",
886
+ sender="bot", chat_container=chat_container,
887
+ key="goodbye")
888
+
889
+ to_llm_mode()
890
+
891
+ # ───────────────────────────────────── intent λͺ¨λ“œ
892
+ def intent_ui(travel_df, external_score_df, festival_df, weather_df, package_df,
893
+ country_filter, city_filter, chat_container, intent, log_and_render):
894
+ """intent(μ˜λ„λ₯Ό μž…λ ₯ν–ˆμ„ 경우) λͺ¨λ“œ μ „μš© UI & 둜직"""
895
+ # ────────────────── μ„Έμ…˜ ν‚€ μ •μ˜
896
+ sample_key = "intent_sample_df"
897
+ step_key = "intent_step"
898
+ prev_key = "intent_prev_places"
899
+ intent_key = "intent_chip_selected"
900
+
901
+ # ────────────────── 0) μ΄ˆκΈ°ν™”
902
+ if step_key not in st.session_state:
903
+ st.session_state[step_key] = "recommend_places"
904
+ st.session_state[prev_key] = set()
905
+ st.session_state.pop(sample_key, None)
906
+
907
+ # ────────────────── 1) restart μƒνƒœλ©΄ 인트둜만 좜λ ₯ν•˜κ³  μ’…λ£Œ
908
+ if st.session_state[step_key] == "restart":
909
+ log_and_render(
910
+ "λ‹€μ‹œ μ—¬ν–‰μ§€λ₯Ό μΆ”μ²œν•΄λ“œλ¦΄κ²Œμš”!<br>μš”μ¦˜ λ– μ˜€λ₯΄λŠ” 여행이 μžˆμœΌμ‹ κ°€μš”?",
911
+ sender="bot",
912
+ chat_container=chat_container,
913
+ key="region_restart_intro"
914
+ )
915
+ return
916
+
917
+ # ────────────────── 2) μ—¬ν–‰μ§€ μΆ”μ²œ 단계
918
+ if st.session_state[step_key] == "recommend_places":
919
+ selected_theme = intent
920
+ theme_df = recommend_places_by_theme(selected_theme, country_filter, city_filter)
921
+ theme_df = theme_df.drop_duplicates(subset=["μ—¬ν–‰λ„μ‹œ"])
922
+ theme_df = theme_df.drop_duplicates(subset=["μ—¬ν–‰μ§€"])
923
+
924
+ # 2.1) 이전 μΆ”μ²œ 기둝 μ„ΈνŒ…
925
+ prev = st.session_state.setdefault(prev_key, set())
926
+
927
+ # 2.2) 이미 μƒ˜ν”Œμ΄ μžˆλ‹€λ©΄ result_df μž¬μ‚¬μš©
928
+ if sample_key in st.session_state and not st.session_state[sample_key].empty:
929
+ result_df = st.session_state[sample_key]
930
+ else:
931
+ # 2.3) μƒˆλ‘œμš΄ μΆ”μ²œ λŒ€μƒ 필터링
932
+ candidates = theme_df[~theme_df["μ—¬ν–‰μ§€"].isin(prev)]
933
+
934
+ # 2.4) 후보가 μ—†λ‹€λ©΄ μ’…λ£Œ
935
+ if candidates.empty:
936
+ st.session_state[step_key] = "recommend_places_end"
937
+ st.rerun()
938
+ return
939
+
940
+ # 2.5) μƒˆλ‘œμš΄ μΆ”μ²œ μΆ”μΆœ 및 μ €μž₯
941
+ result_df = apply_weighted_score_filter(candidates)
942
+ st.session_state[sample_key] = result_df
943
+
944
+ # prev에 λ“±λ‘ν•˜μ—¬ 쀑볡 μΆ”μ²œ λ°©μ§€
945
+ prev.update(result_df["μ—¬ν–‰μ§€"])
946
+ st.session_state[prev_key] = prev
947
+
948
+ # 2.6) μ˜€ν”„λ‹ λ¬Έμž₯ 생성
949
+ opening_line = intent_opening_lines.get(selected_theme, f"'{selected_theme}' μ—¬ν–‰μ§€λ₯Ό μ†Œκ°œν• κ²Œμš”.")
950
+ opening_line = opening_line.format(len(result_df))
951
+
952
+ # 2.7) μΆ”μ²œ λ©”μ‹œμ§€ ꡬ성
953
+ message = "<br>".join([
954
+ f"{i+1}. <strong>{row.μ—¬ν–‰μ§€}</strong> "
955
+ f"({row.μ—¬ν–‰λ‚˜λΌ}, {row.μ—¬ν–‰λ„μ‹œ}) "
956
+ f"{getattr(row, 'ν•œμ€„μ„€λͺ…', 'μ„€λͺ…이 μ—†μŠ΅λ‹ˆλ‹€')}"
957
+ for i, row in enumerate(result_df.itertuples())
958
+ ])
959
+
960
+ # 2.8) 챗봇 좜λ ₯ + μΉ© λ²„νŠΌ λ Œλ”λ§
961
+ with chat_container:
962
+ log_and_render(f"{opening_line}<br><br>{message}",
963
+ sender="bot",
964
+ chat_container=chat_container,
965
+ key=f"intent_recommendation_{random.randint(1,999999)}")
966
+
967
+ recommend_names = result_df["μ—¬ν–‰μ§€"].tolist()
968
+ prev_choice = st.session_state.get(intent_key, None)
969
+ choice = render_chip_buttons(
970
+ recommend_names + ["λ‹€λ₯Έ μ—¬ν–‰μ§€ 보기 πŸ”„"],
971
+ key_prefix="intent_chip",
972
+ selected_value=prev_choice
973
+ )
974
+ # 2.9) 선택 μ—†κ±°λ‚˜ 쀑볡 선택이면 λŒ€κΈ°
975
+ if not choice or choice == prev_choice:
976
+ return
977
+
978
+ # 선택 κ²°κ³Ό 처리
979
+ if choice:
980
+ if choice == "λ‹€λ₯Έ μ—¬ν–‰μ§€ 보기 πŸ”„":
981
+ log_and_render("λ‹€λ₯Έ μ—¬ν–‰μ§€ 보기 πŸ”„",
982
+ sender="user",
983
+ chat_container=chat_container,
984
+ key=f"user_place_refresh_{random.randint(1,999999)}")
985
+
986
+ st.session_state.pop(sample_key, None)
987
+ st.rerun()
988
+ return
989
+
990
+ # 정상 μ„ νƒλœ 경우
991
+ st.session_state[intent_key] = choice
992
+ st.session_state[step_key] = "detail"
993
+ st.session_state.chat_log.append(("user", choice))
994
+
995
+ # μ‹€μ œλ‘œ μ„ νƒλœ μ—¬ν–‰μ§€λ§Œ prev에 기둝
996
+ match = result_df[result_df["μ—¬ν–‰μ§€"] == choice]
997
+ if not match.empty:
998
+ prev.add(choice)
999
+ st.session_state[prev_key] = prev
1000
+
1001
+ # μƒ˜ν”Œ 폐기
1002
+ st.session_state.pop(sample_key, None)
1003
+ st.rerun()
1004
+ return
1005
+
1006
+ # ────────────────── 3) μΆ”μ²œ μ’…λ£Œ 단계
1007
+ elif st.session_state[step_key] == "recommend_places_end":
1008
+ # 3.1) λ©”μ‹œμ§€ 좜λ ₯
1009
+ with chat_container:
1010
+ log_and_render(
1011
+ "⚠️ 더 이상 μƒˆλ‘œμš΄ μ—¬ν–‰μ§€κ°€ μ—†μ–΄μš”.<br>λ‹€μ‹œ μ§ˆλ¬Έν•˜μ‹œκ² μ–΄μš”?",
1012
+ sender="bot",
1013
+ chat_container=chat_container,
1014
+ key="intent_empty"
1015
+ )
1016
+
1017
+ # 3.2) μž¬μ‹œμž‘ μ—¬λΆ€ μΉ© λ²„νŠΌ 좜λ ₯
1018
+ restart_done_key = "intent_restart_done"
1019
+ chip_ph = st.empty()
1020
+
1021
+ if not st.session_state.get(restart_done_key, False):
1022
+ with chip_ph:
1023
+ choice = render_chip_buttons(
1024
+ ["예 πŸ”„", "μ•„λ‹ˆμ˜€ ❌"],
1025
+ key_prefix="intent_restart")
1026
+ else:
1027
+ choice = None
1028
+
1029
+ # 3.3) 아직 아무것도 μ„ νƒν•˜μ§€ μ•Šμ€ 경우
1030
+ if choice is None:
1031
+ return
1032
+
1033
+ chip_ph.empty()
1034
+ st.session_state[restart_done_key] = True
1035
+
1036
+ # 3.4) μ‚¬μš©μž 선택값 좜λ ₯
1037
+ log_and_render(choice,
1038
+ sender="user",
1039
+ chat_container=chat_container
1040
+ )
1041
+
1042
+ # 3.5) μ‚¬μš©μžκ°€ μž¬μΆ”μ²œμ„ μ›ν•˜λŠ” 경우
1043
+ if choice == "예 πŸ”„":
1044
+ for k in [sample_key, prev_key, intent_key, restart_done_key]:
1045
+ st.session_state.pop(k, None)
1046
+ chip_ph.empty()
1047
+
1048
+ # λ‹€μŒ μΆ”μ²œ λ‹¨κ³„λ‘œ μ΄ˆκΈ°ν™”
1049
+ st.session_state["user_input_rendered"] = False
1050
+ st.session_state["intent_step"] = "restart"
1051
+
1052
+ log_and_render(
1053
+ "λ‹€μ‹œ μ—¬ν–‰μ§€λ₯Ό μΆ”μ²œν•΄λ“œλ¦΄κ²Œμš”!<br>μš”μ¦˜ λ– μ˜€λ₯΄λŠ” 여행이 μžˆμœΌμ‹ κ°€μš”?",
1054
+ sender="bot",
1055
+ chat_container=chat_container,
1056
+ key="intent_restart_intro"
1057
+ )
1058
+ return
1059
+
1060
+ # 3.6) μ‚¬μš©μžκ°€ μ’…λ£Œλ₯Ό μ„ νƒν•œ 경우
1061
+ else:
1062
+ log_and_render("μ—¬ν–‰ μΆ”μ²œμ„ μ’…λ£Œν• κ²Œμš”. ν•„μš”ν•˜μ‹€ λ•Œ μ–Έμ œλ“ μ§€ 또 μ°Ύμ•„μ£Όμ„Έμš”! ✈️",
1063
+ sender="bot",
1064
+ chat_container=chat_container,
1065
+ key="intent_exit")
1066
+ st.stop()
1067
+ return
1068
+
1069
+ # ────────────────── 4) μ—¬ν–‰μ§€ 상세 단계
1070
+ if st.session_state[step_key] == "detail":
1071
+ chosen = st.session_state[intent_key]
1072
+ # city 이름 λ½‘μ•„μ„œ μ„Έμ…˜μ— μ €μž₯
1073
+ row = travel_df[travel_df["μ—¬ν–‰μ§€"] == chosen].iloc[0]
1074
+ st.session_state["selected_city"] = row["μ—¬ν–‰λ„μ‹œ"]
1075
+ st.session_state["selected_place"] = chosen
1076
+
1077
+ log_and_render(chosen,
1078
+ sender="user",
1079
+ chat_container=chat_container,
1080
+ key=f"user_place_{chosen}")
1081
+ handle_selected_place(
1082
+ chosen,
1083
+ travel_df,
1084
+ external_score_df,
1085
+ festival_df,
1086
+ weather_df,
1087
+ chat_container=chat_container
1088
+ )
1089
+ st.session_state[step_key] = "companion"
1090
+ st.rerun()
1091
+ return
1092
+
1093
+ # ────────────────── 5) 동행·연령 λ°›κΈ° 단계
1094
+ elif st.session_state[step_key] == "companion":
1095
+ with chat_container:
1096
+ # 5.1) μ•ˆλ‚΄ λ©”μ‹œμ§€ 좜λ ₯
1097
+ log_and_render(
1098
+ "ν•¨κ»˜ κ°€λŠ” λΆ„μ΄λ‚˜ μ—°λ ΉλŒ€λ₯Ό μ•Œλ €μ£Όμ‹œλ©΄ 더 λ”± λ§žλŠ” μƒν’ˆμ„ κ³¨λΌλ“œλ¦΄κ²Œμš”!<br>"
1099
+ "1️⃣ 동행 μ—¬λΆ€ (혼자 / 친ꡬ / μ»€ν”Œ / κ°€μ‘± / 단체)<br>"
1100
+ "2️⃣ μ—°λ ΉλŒ€ (20λŒ€ / 30λŒ€ / 40λŒ€ / 50λŒ€ / 60λŒ€ 이상)",
1101
+ sender="bot",
1102
+ chat_container=chat_container,
1103
+ key="ask_companion_age"
1104
+ )
1105
+
1106
+ # 5.1.1) 동행 μ²΄ν¬λ°•μŠ€
1107
+ st.markdown(
1108
+ '<div style="font-size:14px; font-weight:600; margin:14px 0px 6px 0px;">πŸ‘« 동행 선택</div>',
1109
+ unsafe_allow_html=True
1110
+ )
1111
+ c_cols = st.columns(5)
1112
+ comp_flags = {
1113
+ "혼자": c_cols[0].checkbox("혼자"),
1114
+ "친ꡬ": c_cols[1].checkbox("친ꡬ"),
1115
+ "μ»€ν”Œ": c_cols[2].checkbox("μ»€ν”Œ"),
1116
+ "κ°€μ‘±": c_cols[3].checkbox("κ°€μ‘±"),
1117
+ "단체": c_cols[4].checkbox("단체"),
1118
+ }
1119
+ companions = [k for k, v in comp_flags.items() if v]
1120
+
1121
+ # 5.1.2) μ—°λ Ή μ²΄ν¬λ°•μŠ€
1122
+ st.markdown(
1123
+ '<div style="font-size:14px; font-weight:600; margin:0px 0px 6px 0px;">πŸŽ‚ μ—°λ Ή 선택</div>',
1124
+ unsafe_allow_html=True
1125
+ )
1126
+ a_cols = st.columns(5)
1127
+ age_flags = {
1128
+ "20λŒ€": a_cols[0].checkbox("20λŒ€"),
1129
+ "30λŒ€": a_cols[1].checkbox("30λŒ€"),
1130
+ "40λŒ€": a_cols[2].checkbox("40λŒ€"),
1131
+ "50λŒ€": a_cols[3].checkbox("50λŒ€"),
1132
+ "60λŒ€ 이상": a_cols[4].checkbox("60λŒ€ 이상"),
1133
+ }
1134
+ age_group = [k for k, v in age_flags.items() if v]
1135
+
1136
+ # 5.1.3) 확인 λ²„νŠΌ
1137
+ confirm = st.button(
1138
+ "μΆ”μ²œ λ°›κΈ°",
1139
+ key="btn_confirm_companion",
1140
+ disabled=not (companions or age_group),
1141
+ )
1142
+
1143
+ # 5.2) λ©”μ‹œμ§€ 좜λ ₯
1144
+ if confirm:
1145
+ # μ‚¬μš©μž 버블 좜λ ₯
1146
+ user_msg = " / ".join(companions + age_group)
1147
+ log_and_render(
1148
+ user_msg if user_msg else "선택 μ•ˆ 함",
1149
+ sender="user",
1150
+ chat_container=chat_container,
1151
+ key=f"user_comp_age_{random.randint(1,999999)}"
1152
+ )
1153
+
1154
+ # μ„Έμ…˜ μ €μž₯
1155
+ st.session_state["companions"] = companions or None
1156
+ st.session_state["age_group"] = age_group or None
1157
+
1158
+ # λ‹€μŒ μŠ€ν…
1159
+ st.session_state[step_key] = "package"
1160
+ st.rerun()
1161
+ return
1162
+
1163
+ # ────────────────── 6) 동행·연령 필터링· νŒ¨ν‚€μ§€ 좜λ ₯ 단계
1164
+ elif st.session_state[step_key] == "package":
1165
+
1166
+ # νŒ¨ν‚€μ§€ 버블을 이미 λ§Œλ“€μ—ˆμœΌλ©΄ κ±΄λ„ˆλœ€
1167
+ if st.session_state.get("package_rendered", False):
1168
+ st.session_state[step_key] = "package_end"
1169
+ return
1170
+
1171
+ companions = st.session_state.get("companions")
1172
+ age_group = st.session_state.get("age_group")
1173
+ city = st.session_state.get("selected_city")
1174
+ place = st.session_state.get("selected_place")
1175
+
1176
+ filtered = filter_packages_by_companion_age(
1177
+ package_df, companions, age_group, city=city, top_n=2
1178
+ )
1179
+
1180
+ if filtered.empty:
1181
+ log_and_render(
1182
+ "⚠️ μ•„μ‰½μ§€λ§Œ μ§€κΈˆ 쑰건에 λ§žλŠ” νŒ¨ν‚€μ§€κ°€ μ—†μ–΄μš”.<br>"
1183
+ "λ‹€λ₯Έ 쑰건으둜 λ‹€μ‹œ μ°Ύμ•„λ³ΌκΉŒμš”?",
1184
+ sender="bot", chat_container=chat_container,
1185
+ key="no_package"
1186
+ )
1187
+ st.session_state[step_key] = "companion" # λ‹€μ‹œ μž…λ ₯ λ‹¨κ³„λ‘œ
1188
+ st.rerun()
1189
+ return
1190
+
1191
+ combo_msg = make_companion_age_message(companions, age_group)
1192
+ header = f"{combo_msg}"
1193
+
1194
+ # νŒ¨ν‚€μ§€ μΉ΄λ“œ 좜λ ₯
1195
+ used_phrases = set()
1196
+ theme_row = travel_df[travel_df["μ—¬ν–‰μ§€"] == place]
1197
+ raw_theme = theme_row["ν†΅ν•©ν…Œλ§ˆλͺ…"].iloc[0] if not theme_row.empty else None
1198
+ selected_ui_theme = theme_ui_map.get(raw_theme, (raw_theme,))[0]
1199
+
1200
+ title_candidates = theme_title_phrases.get(selected_ui_theme, ["μΆ”μ²œ"])
1201
+ sampled_titles = random.sample(title_candidates,
1202
+ k=min(2, len(title_candidates)))
1203
+
1204
+ # λ©”μ‹œμ§€ 생성
1205
+ pkg_msgs = [header]
1206
+
1207
+ for i, (_, row) in enumerate(filtered.iterrows(), 1):
1208
+ desc, used_phrases = make_top2_description_custom(
1209
+ row.to_dict(), used_phrases
1210
+ )
1211
+ tags = format_summary_tags_custom(row["μš”μ•½μ •λ³΄"])
1212
+ title_phrase = (sampled_titles[i-1] if i <= len(sampled_titles)
1213
+ else random.choice(title_candidates))
1214
+ title = f"{city} {title_phrase} νŒ¨ν‚€μ§€"
1215
+ url = row.URL
1216
+
1217
+ pkg_msgs.append(
1218
+ f"{i}. <strong>{title}</strong><br>"
1219
+ f"πŸ…Ό {desc}<br>{tags}<br>"
1220
+ f'<a href="{url}" target="_blank" rel="noopener noreferrer" '
1221
+ 'style="text-decoration:none;font-weight:600;color:#009c75;">'
1222
+ 'πŸ’š λ°”λ‘œκ°€κΈ°&nbsp;β†—</a>'
1223
+ )
1224
+ # λ©”μ‹œμ§€ 좜λ ₯
1225
+ log_and_render(
1226
+ "<br><br>".join(pkg_msgs),
1227
+ sender="bot",
1228
+ chat_container=chat_container,
1229
+ key=f"pkg_bundle_{random.randint(1,999999)}"
1230
+ )
1231
+
1232
+ # μ„Έμ…˜ 정리
1233
+ st.session_state["package_rendered"] = True
1234
+ st.session_state[step_key] = "package_end"
1235
+ show_llm_inline() # ν”Œλž˜κ·Έλ§Œ ON (rerun μ—†μŒ)
1236
+ render_llm_followup(chat_container, inline=True) # πŸ‘ˆ 같은 μ‚¬μ΄ν΄μ—μ„œ λ°”λ‘œ μ•„λž˜μ— LLM λ°•μŠ€ 좜λ ₯
1237
+ return
1238
+
1239
+ # ────────────────── 7) μ’…λ£Œ 단계
1240
+ elif st.session_state[step_key] == "package_end":
1241
+ log_and_render("ν•„μš”ν•˜μ‹€ λ•Œ μ–Έμ œλ“ μ§€ 또 μ°Ύμ•„μ£Όμ„Έμš”! ✈️",
1242
+ sender="bot", chat_container=chat_container,
1243
+ key="goodbye")
1244
+
1245
+ to_llm_mode()
1246
+
1247
+ # ───────────────────────────────────── emotion λͺ¨λ“œ
1248
+ def emotion_ui(travel_df, external_score_df, festival_df, weather_df, package_df,
1249
+ country_filter, city_filter, chat_container, candidate_themes,
1250
+ intent, emotion_groups, top_emotions, log_and_render):
1251
+ """emotion(감정을 μž…λ ₯ν–ˆμ„ 경우) λͺ¨λ“œ μ „μš© UI & 둜직"""
1252
+
1253
+ # ────────────────── μ„Έμ…˜ ν‚€ μ •μ˜
1254
+ sample_key = "emotion_sample_df"
1255
+ step_key = "emotion_step"
1256
+ theme_key = "selected_theme"
1257
+ emotion_key = "emotion_chip_selected"
1258
+ prev_key = "emotion_prev_places"
1259
+
1260
+ # ────────────────── 0) μ΄ˆκΈ°ν™”
1261
+ if step_key not in st.session_state:
1262
+ st.session_state[step_key] = "theme_selection"
1263
+ st.session_state[prev_key] = set()
1264
+ st.session_state.pop(sample_key, None)
1265
+
1266
+
1267
+ # ────────────────── 1) restart μƒνƒœλ©΄ 인트둜만 좜λ ₯ν•˜κ³  μ’…λ£Œ
1268
+ if st.session_state[step_key] == "restart":
1269
+ log_and_render(
1270
+ "λ‹€μ‹œ μ—¬ν–‰μ§€λ₯Ό μΆ”μ²œν•΄λ“œλ¦΄κ²Œμš”!<br>μš”μ¦˜ λ– μ˜€λ₯΄λŠ” 여행이 μžˆμœΌμ‹ κ°€μš”?",
1271
+ sender="bot",
1272
+ chat_container=chat_container,
1273
+ key="region_restart_intro"
1274
+ )
1275
+ return
1276
+
1277
+ # ────────────────── 2) ν…Œλ§ˆ μΆ”μ²œ 단계
1278
+ if st.session_state[step_key] == "theme_selection":
1279
+ # μΆ”μ²œ ν…Œλ§ˆ 1개일 경우
1280
+ if len(candidate_themes) == 1:
1281
+ selected_theme = candidate_themes[0]
1282
+ st.session_state[theme_key] = selected_theme
1283
+ log_and_render(f"μΆ”μ²œ κ°€λŠ₯ν•œ ν…Œλ§ˆκ°€ 1κ°œμ΄λ―€λ‘œ '{selected_theme}'을 μ„ νƒν• κ²Œμš”.", sender="bot", chat_container=chat_container)
1284
+ st.session_state[step_key] = "recommend_places"
1285
+ st.rerun()
1286
+
1287
+ # ν…Œλ§ˆκ°€ μ—¬λŸ¬ 개일 경우
1288
+ else:
1289
+ # 인트둜 λ©”μ‹œμ§€
1290
+ intro_msg = generate_intro_message(intent=intent, emotion_groups=emotion_groups, emotion_scores=top_emotions)
1291
+ log_and_render(f"{intro_msg}<br>μ•„λž˜ 쀑 마음이 λŒλ¦¬λŠ” μ—¬ν–‰ μŠ€νƒ€μΌμ„ κ³¨λΌμ£Όμ„Έμš” πŸ’«", sender="bot", chat_container=chat_container)
1292
+
1293
+ # 후보 ν…Œλ§ˆ μ€€λΉ„
1294
+ dfs = [recommend_places_by_theme(t, country_filter, city_filter) for t in candidate_themes]
1295
+ dfs = [df for df in dfs if not df.empty]
1296
+ all_theme_df = pd.concat(dfs) if dfs else pd.DataFrame(columns=travel_df.columns)
1297
+ all_theme_df = all_theme_df.drop_duplicates(subset=["μ—¬ν–‰μ§€"])
1298
+ all_theme_names = all_theme_df["ν†΅ν•©ν…Œλ§ˆλͺ…"].dropna().tolist()
1299
+
1300
+ available_themes = []
1301
+ for t in candidate_themes:
1302
+ if t in all_theme_names and t not in available_themes:
1303
+ available_themes.append(t)
1304
+ for t in all_theme_names:
1305
+ if t not in available_themes:
1306
+ available_themes.append(t)
1307
+ available_themes = available_themes[:3] # μ΅œλŒ€ 3개
1308
+
1309
+ # μΉ© UI 좜λ ₯
1310
+ with chat_container:
1311
+ chip = render_chip_buttons(
1312
+ [theme_ui_map.get(t, (t, ""))[0] for t in available_themes],
1313
+ key_prefix="theme_chip"
1314
+ )
1315
+
1316
+ # 선택이 μ™„λ£Œλ˜λ©΄ λ‹€μŒ λ‹¨κ³„λ‘œ 이동
1317
+ if chip:
1318
+ selected_theme = ui_to_theme_map.get(chip, chip)
1319
+ st.session_state[theme_key] = selected_theme
1320
+ st.session_state[step_key] = "recommend_places"
1321
+ st.session_state["emotion_all_theme_df"] = all_theme_df
1322
+ log_and_render(f"{chip}", sender="user",
1323
+ chat_container=chat_container)
1324
+
1325
+ st.rerun()
1326
+
1327
+ # ────────────────── 3) μ—¬ν–‰μ§€ μΆ”μ²œ 단계
1328
+ if st.session_state[step_key] == "recommend_places":
1329
+ all_theme_df = st.session_state.get("emotion_all_theme_df", pd.DataFrame())
1330
+ selected_theme = st.session_state.get(theme_key, "")
1331
+
1332
+ prev_key = "emotion_prev_places"
1333
+ prev = st.session_state.setdefault(prev_key, set())
1334
+
1335
+ # μ˜ˆμ™Έ 처리: 데이터 없을 경우
1336
+ if all_theme_df.empty or not selected_theme:
1337
+ log_and_render("μΆ”μ²œ 데이터λ₯Ό λΆˆλŸ¬μ˜€λŠ” 데 λ¬Έμ œκ°€ λ°œμƒν–ˆμ–΄μš”. <br>λ‹€μ‹œ μž…λ ₯ν•΄ μ£Όμ„Έμš”.", sender="bot", chat_container=chat_container)
1338
+ return
1339
+
1340
+ if sample_key not in st.session_state:
1341
+ theme_df = all_theme_df[all_theme_df["ν†΅ν•©ν…Œλ§ˆλͺ…"] == selected_theme]
1342
+ theme_df = theme_df.drop_duplicates(subset=["μ—¬ν–‰λ„μ‹œ"])
1343
+ theme_df = theme_df.drop_duplicates(subset=["μ—¬ν–‰μ§€"])
1344
+ remaining = theme_df[~theme_df["μ—¬ν–‰μ§€"].isin(prev)]
1345
+
1346
+ if remaining.empty:
1347
+ st.session_state[step_key] = "recommend_places_end"
1348
+ st.rerun()
1349
+ return
1350
+
1351
+ result_df = apply_weighted_score_filter(remaining)
1352
+ st.session_state[sample_key] = result_df
1353
+ else:
1354
+ result_df = st.session_state[sample_key]
1355
+
1356
+ # μΆ”μ²œ 수 λΆ€μ‘±ν•  경우 Fallback 보완
1357
+ if len(result_df) < 3:
1358
+ fallback = travel_df[
1359
+ (travel_df["ν†΅ν•©ν…Œλ§ˆλͺ…"] == selected_theme) &
1360
+ (~travel_df["μ—¬ν–‰μ§€"].isin(result_df["μ—¬ν–‰μ§€"]))
1361
+ ].drop_duplicates(subset=["μ—¬ν–‰μ§€"])
1362
+
1363
+ if not fallback.empty:
1364
+ fill_count = min(3 - len(result_df), len(fallback))
1365
+ fill = fallback.sample(n=fill_count, random_state=random.randint(1, 9999))
1366
+ result_df = pd.concat([result_df, fill], ignore_index=True)
1367
+
1368
+ # μƒ˜ν”Œ μ €μž₯
1369
+ st.session_state[sample_key] = result_df
1370
+
1371
+ # 2.1)첫 λ¬Έμž₯ 좜λ ₯
1372
+ ui_name = theme_ui_map.get(selected_theme, (selected_theme,))[0]
1373
+ opening_line_template = theme_opening_lines.get(ui_name)
1374
+ opening_line = opening_line_template.format(len(result_df)) if opening_line_template else ""
1375
+
1376
+ message = (
1377
+ "<br>".join([
1378
+ f"{i+1}. <strong>{row.μ—¬ν–‰μ§€}</strong> "
1379
+ f"({row.μ—¬ν–‰λ‚˜λΌ}, {row.μ—¬ν–‰λ„μ‹œ}) "
1380
+ f"{getattr(row, 'ν•œμ€„μ„€λͺ…', 'μ„€λͺ…이 μ—†μŠ΅λ‹ˆλ‹€')}"
1381
+ for i, row in enumerate(result_df.itertuples())
1382
+ ])
1383
+ )
1384
+ if opening_line_template:
1385
+ message_combined = f"{opening_line}<br><br>{message}"
1386
+ with chat_container:
1387
+ log_and_render(message_combined,
1388
+ sender="bot",
1389
+ chat_container=chat_container,
1390
+ key=f"emotion_recommendation_{random.randint(1,999999)}"
1391
+ )
1392
+ # 2.2) μΉ© λ²„νŠΌμœΌλ‘œ μΆ”μ²œμ§€ 쀑 μ„ οΏ½οΏ½λ°›κΈ°
1393
+ recommend_names = result_df["μ—¬ν–‰μ§€"].tolist()
1394
+ prev_choice = st.session_state.get(emotion_key, None)
1395
+ choice = render_chip_buttons(
1396
+ recommend_names + ["λ‹€λ₯Έ μ—¬ν–‰μ§€ 보기 πŸ”„"],
1397
+ key_prefix="emotion_chip",
1398
+ selected_value=prev_choice
1399
+ )
1400
+
1401
+ # 2.3) 선택 κ²°κ³Ό 처리
1402
+ if not choice or choice == prev_choice:
1403
+ return
1404
+
1405
+ if choice == "λ‹€λ₯Έ μ—¬ν–‰μ§€ 보기 πŸ”„":
1406
+ log_and_render("λ‹€λ₯Έ μ—¬ν–‰μ§€ 보기 πŸ”„",
1407
+ sender="user",
1408
+ chat_container=chat_container,
1409
+ key=f"user_place_refresh_{random.randint(1,999999)}")
1410
+
1411
+ st.session_state.pop(sample_key, None)
1412
+ st.rerun()
1413
+ return
1414
+
1415
+ # μ‹€μ œ μ„ νƒν•œ μ—¬ν–‰μ§€ 처리
1416
+ st.session_state[emotion_key] = choice
1417
+ st.session_state[step_key] = "detail"
1418
+ st.session_state.chat_log.append(("user", choice))
1419
+
1420
+ # μ„ νƒν•œ μ—¬ν–‰μ§€λ₯Ό prev 기둝에 μΆ”κ°€
1421
+ match = result_df[result_df["μ—¬ν–‰μ§€"] == choice]
1422
+ if not match.empty:
1423
+ prev.add(choice)
1424
+ st.session_state[prev_key] = prev
1425
+
1426
+ # μƒ˜ν”Œ 폐기
1427
+ st.session_state.pop(sample_key, None)
1428
+ st.rerun()
1429
+ return
1430
+
1431
+ # ────────────────── 3) μΆ”μ²œ μ’…λ£Œ 단계: 더 이상 μΆ”μ²œν•  μ—¬ν–‰μ§€κ°€ 없을 λ•Œ
1432
+ elif st.session_state[step_key] == "recommend_place_end":
1433
+ with chat_container:
1434
+ # 3.1) λ©”μ‹œμ§€ 좜λ ₯
1435
+ log_and_render(
1436
+ "⚠️ 더 이상 μƒˆλ‘œμš΄ μ—¬ν–‰μ§€κ°€ μ—†μ–΄μš”.<br>λ‹€μ‹œ μ§ˆλ¬Έν•˜μ‹œκ² μ–΄μš”?",
1437
+ sender="bot",
1438
+ chat_container=chat_container,
1439
+ key="emotion_empty"
1440
+ )
1441
+ # 3.2) μž¬μ‹œμž‘ μ—¬λΆ€ μΉ© λ²„νŠΌ 좜λ ₯
1442
+ restart_done_key = "emotion_restart_done"
1443
+ chip_ph = st.empty()
1444
+
1445
+ if not st.session_state.get(restart_done_key, False):
1446
+ with chip_ph:
1447
+ choice = render_chip_buttons(
1448
+ ["예 πŸ”„", "μ•„λ‹ˆμ˜€ ❌"],
1449
+ key_prefix="emotion_restart"
1450
+ )
1451
+ else:
1452
+ choice = None
1453
+
1454
+ # 3.3) 아직 아무것도 μ„ νƒν•˜μ§€ μ•Šμ€ 경우
1455
+ if choice is None:
1456
+ return
1457
+
1458
+ chip_ph.empty()
1459
+ st.session_state[restart_done_key] = True
1460
+
1461
+ # 3.4) μ‚¬μš©μž 선택값 좜λ ₯
1462
+ log_and_render(
1463
+ choice,
1464
+ sender="user",
1465
+ chat_container=chat_container,
1466
+ key=f"user_restart_choice_{choice}"
1467
+ )
1468
+
1469
+ # 3.5) μ‚¬μš©μžκ°€ μž¬μΆ”μ²œμ„ μ›ν•˜λŠ” 경우
1470
+ if choice == "예 πŸ”„":
1471
+ # μ—¬ν–‰ μΆ”μ²œ μƒνƒœ μ΄ˆκΈ°ν™”
1472
+ for k in [emotion_key, prev_key, sample_key, restart_done_key]:
1473
+ st.session_state.pop(k, None)
1474
+ chip_ph.empty()
1475
+
1476
+ # λ‹€μŒ μΆ”μ²œ λ‹¨κ³„λ‘œ μ΄ˆκΈ°ν™”
1477
+ st.session_state["user_input_rendered"] = False
1478
+ st.session_state["emotion_step"] = "restart"
1479
+
1480
+ log_and_render(
1481
+ "λ‹€μ‹œ μ—¬ν–‰μ§€λ₯Ό μΆ”μ²œν•΄λ“œλ¦΄κ²Œμš”!<br>μš”μ¦˜ λ– μ˜€λ₯΄λŠ” 여행이 μžˆμœΌμ‹ κ°€μš”?",
1482
+ sender="bot",
1483
+ chat_container=chat_container,
1484
+ key="emotion_restart_intro"
1485
+ )
1486
+ return
1487
+
1488
+ # 3.6) μ‚¬μš©μžκ°€ μ’…λ£Œλ₯Ό μ„ νƒν•œ 경우
1489
+ else:
1490
+ log_and_render("μ—¬ν–‰ μΆ”μ²œμ„ μ’…λ£Œν• κ²Œμš”. ν•„μš”ν•˜μ‹€ λ•Œ μ–Έμ œλ“ μ§€ 또 μ°Ύμ•„μ£Όμ„Έμš”! ✈️",
1491
+ sender="bot",
1492
+ chat_container=chat_container,
1493
+ key="emotion_exit")
1494
+ st.stop()
1495
+ return
1496
+
1497
+ # ────────────────── 4) μ—¬ν–‰μ§€ 상세 단계
1498
+ if st.session_state[step_key] == "detail":
1499
+ chosen = st.session_state[emotion_key]
1500
+ # city 이름 λ½‘μ•„μ„œ μ„Έμ…˜μ— μ €μž₯
1501
+ row = travel_df[travel_df["μ—¬ν–‰μ§€"] == chosen].iloc[0]
1502
+ st.session_state["selected_city"] = row["μ—¬ν–‰λ„μ‹œ"]
1503
+ st.session_state["selected_place"] = chosen
1504
+
1505
+ log_and_render(chosen,
1506
+ sender="user",
1507
+ chat_container=chat_container,
1508
+ key=f"user_place_{chosen}")
1509
+ handle_selected_place(
1510
+ chosen,
1511
+ travel_df,
1512
+ external_score_df,
1513
+ festival_df,
1514
+ weather_df,
1515
+ chat_container=chat_container
1516
+ )
1517
+ st.session_state[step_key] = "companion"
1518
+ st.rerun()
1519
+ return
1520
+
1521
+ # ────────────────── 5) 동행·연령 λ°›κΈ° 단계
1522
+ elif st.session_state[step_key] == "companion":
1523
+ with chat_container:
1524
+ # 5.1) μ•ˆλ‚΄ λ©”μ‹œμ§€ 좜λ ₯
1525
+ log_and_render(
1526
+ "ν•¨κ»˜ κ°€λŠ” λΆ„μ΄λ‚˜ μ—°λ ΉλŒ€λ₯Ό μ•Œλ €μ£Όμ‹œλ©΄ 더 λ”± λ§žλŠ” μƒν’ˆμ„ κ³¨λΌλ“œλ¦΄κ²Œμš”!<br>"
1527
+ "1️⃣ 동행 μ—¬λΆ€ (혼자 / 친ꡬ / μ»€ν”Œ / κ°€μ‘± / 단체)<br>"
1528
+ "2️⃣ μ—°λ ΉλŒ€ (20λŒ€ / 30λŒ€ / 40λŒ€ / 50λŒ€ / 60λŒ€ 이상)",
1529
+ sender="bot",
1530
+ chat_container=chat_container,
1531
+ key="ask_companion_age"
1532
+ )
1533
+
1534
+ # 5.1.1) 동행 μ²΄ν¬λ°•μŠ€
1535
+ st.markdown(
1536
+ '<div style="font-size:14px; font-weight:600; margin:14px 0px 6px 0px;">πŸ‘« 동행 선택</div>',
1537
+ unsafe_allow_html=True
1538
+ )
1539
+ c_cols = st.columns(5)
1540
+ comp_flags = {
1541
+ "혼자": c_cols[0].checkbox("혼자"),
1542
+ "친ꡬ": c_cols[1].checkbox("친ꡬ"),
1543
+ "μ»€ν”Œ": c_cols[2].checkbox("μ»€ν”Œ"),
1544
+ "κ°€μ‘±": c_cols[3].checkbox("κ°€μ‘±"),
1545
+ "단체": c_cols[4].checkbox("단체"),
1546
+ }
1547
+ companions = [k for k, v in comp_flags.items() if v]
1548
+
1549
+ # 5.1.2) μ—°λ Ή μ²΄ν¬λ°•μŠ€
1550
+ st.markdown(
1551
+ '<div style="font-size:14px; font-weight:600; margin:0px 0px 6px 0px;">πŸŽ‚ μ—°λ Ή 선택</div>',
1552
+ unsafe_allow_html=True
1553
+ )
1554
+ a_cols = st.columns(5)
1555
+ age_flags = {
1556
+ "20λŒ€": a_cols[0].checkbox("20λŒ€"),
1557
+ "30λŒ€": a_cols[1].checkbox("30λŒ€"),
1558
+ "40λŒ€": a_cols[2].checkbox("40λŒ€"),
1559
+ "50λŒ€": a_cols[3].checkbox("50λŒ€"),
1560
+ "60λŒ€ 이상": a_cols[4].checkbox("60λŒ€ 이상"),
1561
+ }
1562
+ age_group = [k for k, v in age_flags.items() if v]
1563
+
1564
+ # 5.1.3) 확인 λ²„νŠΌ
1565
+ confirm = st.button(
1566
+ "μΆ”μ²œ λ°›κΈ°",
1567
+ key="btn_confirm_companion",
1568
+ disabled=not (companions or age_group),
1569
+ )
1570
+
1571
+ # 5.2) λ©”μ‹œμ§€ 좜λ ₯
1572
+ if confirm:
1573
+ # μ‚¬μš©μž 버블 좜λ ₯
1574
+ user_msg = " / ".join(companions + age_group)
1575
+ log_and_render(
1576
+ user_msg if user_msg else "선택 μ•ˆ 함",
1577
+ sender="user",
1578
+ chat_container=chat_container,
1579
+ key=f"user_comp_age_{random.randint(1,999999)}"
1580
+ )
1581
+
1582
+ # μ„Έμ…˜ μ €μž₯
1583
+ st.session_state["companions"] = companions or None
1584
+ st.session_state["age_group"] = age_group or None
1585
+
1586
+ # λ‹€μŒ μŠ€ν…
1587
+ st.session_state[step_key] = "package"
1588
+ st.rerun()
1589
+ return
1590
+
1591
+ # ────────────────── 6) 동행·연령 필터링· νŒ¨ν‚€μ§€ 좜λ ₯ 단계
1592
+ elif st.session_state[step_key] == "package":
1593
+
1594
+ # νŒ¨ν‚€μ§€ 버블을 이미 λ§Œλ“€μ—ˆμœΌλ©΄ κ±΄λ„ˆλœ€
1595
+ if st.session_state.get("package_rendered", False):
1596
+ st.session_state[step_key] = "package_end"
1597
+ return
1598
+
1599
+ companions = st.session_state.get("companions")
1600
+ age_group = st.session_state.get("age_group")
1601
+ city = st.session_state.get("selected_city")
1602
+ place = st.session_state.get("selected_place")
1603
+
1604
+ filtered = filter_packages_by_companion_age(
1605
+ package_df, companions, age_group, city=city, top_n=2
1606
+ )
1607
+
1608
+ if filtered.empty:
1609
+ log_and_render(
1610
+ "⚠️ μ•„μ‰½μ§€λ§Œ μ§€κΈˆ 쑰건에 λ§žλŠ” νŒ¨ν‚€μ§€κ°€ μ—†μ–΄μš”.<br>"
1611
+ "λ‹€λ₯Έ 쑰건으둜 λ‹€μ‹œ μ°Ύμ•„λ³ΌκΉŒμš”?",
1612
+ sender="bot", chat_container=chat_container,
1613
+ key="no_package"
1614
+ )
1615
+ st.session_state[step_key] = "companion"
1616
+ st.rerun()
1617
+ return
1618
+
1619
+ combo_msg = make_companion_age_message(companions, age_group)
1620
+ header = f"{combo_msg}"
1621
+
1622
+ # νŒ¨ν‚€μ§€ μΉ΄λ“œ 좜λ ₯
1623
+ used_phrases = set()
1624
+ theme_row = travel_df[travel_df["μ—¬ν–‰μ§€"] == place]
1625
+ raw_theme = theme_row["ν†΅ν•©ν…Œλ§ˆλͺ…"].iloc[0] if not theme_row.empty else None
1626
+ selected_ui_theme = theme_ui_map.get(raw_theme, (raw_theme,))[0]
1627
+
1628
+ title_candidates = theme_title_phrases.get(selected_ui_theme, ["μΆ”μ²œ"])
1629
+ sampled_titles = random.sample(title_candidates,
1630
+ k=min(2, len(title_candidates)))
1631
+
1632
+ # λ©”μ‹œμ§€ 생성
1633
+ pkg_msgs = [header]
1634
+
1635
+ for i, (_, row) in enumerate(filtered.iterrows(), 1):
1636
+ desc, used_phrases = make_top2_description_custom(
1637
+ row.to_dict(), used_phrases
1638
+ )
1639
+ tags = format_summary_tags_custom(row["μš”μ•½μ •λ³΄"])
1640
+ title_phrase = (sampled_titles[i-1] if i <= len(sampled_titles)
1641
+ else random.choice(title_candidates))
1642
+ title = f"{city} {title_phrase} νŒ¨ν‚€μ§€"
1643
+ url = row.URL
1644
+
1645
+ pkg_msgs.append(
1646
+ f"{i}. <strong>{title}</strong><br>"
1647
+ f"πŸ…Ό {desc}<br>{tags}<br>"
1648
+ f'<a href="{url}" target="_blank" rel="noopener noreferrer" '
1649
+ 'style="text-decoration:none;font-weight:600;color:#009c75;">'
1650
+ 'πŸ’š λ°”λ‘œκ°€κΈ°&nbsp;β†—</a>'
1651
+ )
1652
+ # λ©”μ‹œμ§€ 좜λ ₯
1653
+ log_and_render(
1654
+ "<br><br>".join(pkg_msgs),
1655
+ sender="bot",
1656
+ chat_container=chat_container,
1657
+ key=f"pkg_bundle_{random.randint(1,999999)}"
1658
+ )
1659
+
1660
+ # μ„Έμ…˜ 정리
1661
+ st.session_state["package_rendered"] = True
1662
+ st.session_state[step_key] = "package_end"
1663
+ show_llm_inline() # ν”Œλž˜κ·Έλ§Œ ON (rerun μ—†μŒ)
1664
+ render_llm_followup(chat_container, inline=True) # πŸ‘ˆ 같은 μ‚¬μ΄ν΄μ—μ„œ λ°”λ‘œ μ•„λž˜μ— LLM λ°•μŠ€ 좜λ ₯
1665
+ return
1666
+
1667
+ # ────────────────── 7) μ’…λ£Œ 단계
1668
+ elif st.session_state[step_key] == "package_end":
1669
+ log_and_render("ν•„μš”ν•˜μ‹€ λ•Œ μ–Έμ œλ“ μ§€ 또 μ°Ύμ•„μ£Όμ„Έμš”! ✈️",
1670
+ sender="bot", chat_container=chat_container,
1671
+ key="goodbye")
1672
+
1673
+ to_llm_mode()
1674
+
1675
+ # ───────────────────────────────────── unknown λͺ¨λ“œ
1676
+ def unknown_ui(country, city, chat_container, log_and_render):
1677
+ """unknown λͺ¨λ“œ(아직 DB에 μ—†λŠ” λ‚˜λΌΒ·λ„μ‹œμΌ λ•Œ μ•ˆλ‚΄) μ „μš© UI & 둜직"""
1678
+ # μ•ˆλ‚΄ λ©”μ‹œμ§€
1679
+ if city:
1680
+ msg = (f"πŸ” μ£„μ†‘ν•΄μš”. ν•΄λ‹Ή <strong>{city}</strong>의 μ—¬ν–‰μ§€λŠ” "
1681
+ "아직 μ€€λΉ„ μ€‘μ΄μ—μš”.<br> λΉ λ₯Έ μ‹œμΌ μ•ˆμ— μ—…λ°μ΄νŠΈν• κ²Œμš”!")
1682
+ elif country:
1683
+ msg = (f"πŸ” μ£„μ†‘ν•΄μš”. ν•΄λ‹Ή <strong>{country}</strong>의 μ—¬ν–‰μ§€λŠ” "
1684
+ "아직 μ€€λΉ„ μ€‘μ΄μ—μš”.<br> λΉ λ₯Έ μ‹œμΌ μ•ˆμ— μ—…λ°μ΄νŠΈν• κ²Œμš”!")
1685
+ else:
1686
+ msg = "πŸ” μ£„μ†‘ν•΄μš”. ν•΄λ‹Ή μ—¬ν–‰μ§€λŠ” 아직 μ€€λΉ„ μ€‘μ΄μ—μš”."
1687
+
1688
+ with chat_container:
1689
+ log_and_render(
1690
+ f"{msg}",
1691
+ sender="bot",
1692
+ chat_container=chat_container,
1693
+ key="unknown_dest"
1694
+ )
1695
+
1696
+ # def _get_active_step_key():
1697
+ # mode = st.session_state.get("mode", "unknown")
1698
+ # mapping = {
1699
+ # "region": "region_step",
1700
+ # "intent": "intent_step",
1701
+ # "emotion": "emotion_step",
1702
+ # "theme_selection": "theme_step",
1703
+ # "place_selection": "place_step",
1704
+ # "user_info_input": "user_info_step",
1705
+ # }
1706
+ # # 맀핑에 μ—†μœΌλ©΄ 곡용 ν‚€λ‘œ
1707
+ # return mapping.get(mode, "flow_step")
1708
+ # ───────────────────────────────────── 챗봇 호좜
1709
+ def main():
1710
+
1711
+ init_session()
1712
+ chat_container = st.container()
1713
+
1714
+ # βœ… ν’€μŠ€ν¬λ¦°μΌ λ•Œλ§Œ μ‘°κΈ° 리턴
1715
+ if st.session_state.get("llm_mode") and not st.session_state.get("llm_inline", False):
1716
+ render_llm_followup(chat_container, inline=False)
1717
+ return
1718
+
1719
+ # πŸŽ›οΈ 말풍선/ν‘œμ‹œ μ˜΅μ…˜ (β‘’, β‘£)
1720
+ st.sidebar.subheader("βš™οΈ λŒ€ν™” ν‘œμ‹œ")
1721
+ st.sidebar.selectbox("ν…Œλ§ˆ", ["ν”ΌμŠ€νƒ€μΉ˜μ˜€", "μŠ€μΉ΄μ΄λΈ”λ£¨", "크리미였트"], key="bubble_theme")
1722
+ st.sidebar.toggle("νƒ€μž„μŠ€νƒ¬ν”„ ν‘œμ‹œ", value=False, key="show_time")
1723
+
1724
+ # with st.sidebar.expander("DEBUG steps", expanded=False):
1725
+ # st.write("mode:", st.session_state.get("mode"))
1726
+ # st.write("step_key:", cur_step_key)
1727
+ # st.write("state:", st.session_state.get(cur_step_key))
1728
+
1729
+
1730
+ # βœ… νƒ€μž 효과 on/off ν† κΈ€ (κΈ°λ³Έ ON)
1731
+ st.sidebar.toggle("νƒ€μž 효과", value=False, key="typewriter_on")
1732
+
1733
+ if "chat_log" in st.session_state and st.session_state.chat_log:
1734
+ replay_log(chat_container)
1735
+
1736
+ # ───── greeting λ©”μ‹œμ§€ 좜λ ₯
1737
+ if not st.session_state.get("greeting_rendered", False):
1738
+ greeting_message = (
1739
+ "μ•ˆλ…•ν•˜μ„Έμš”. <strong>λͺ¨μ•„(MoAi)</strong>μž…λ‹ˆλ‹€.πŸ€–<br><br>"
1740
+ "μš”μ¦˜ μ–΄λ–€ 여행이 λ– μ˜€λ₯΄μ„Έμš”?<br>""λͺ¨μ•„κ°€ λ”± λ§žλŠ” μ—¬ν–‰μ§€λ₯Ό μ°Ύμ•„λ“œλ¦΄κ²Œμš”."
1741
+ )
1742
+ log_and_render(
1743
+ greeting_message,
1744
+ sender="bot",
1745
+ chat_container=chat_container,
1746
+ key="greeting"
1747
+ )
1748
+ st.session_state["greeting_rendered"] = True
1749
+
1750
+
1751
+ # ───── μ‚¬μš©μž μž…λ ₯ & μΆ”μ²œ μ‹œμž‘
1752
+ # 1) μ‚¬μš©μž μž…λ ₯
1753
+ user_input = st.text_input(
1754
+ "μž…λ ₯μ°½", # λΉ„μ–΄μžˆμ§€ μ•Šμ€ 라벨(μ ‘κ·Όμ„± 확보)
1755
+ placeholder="ex)'μš”μ¦˜ 힐링이 ν•„μš”ν•΄μš”', 'κ°€μ‘± μ—¬ν–‰ μ–΄λ””κ°€ μ’‹μ„κΉŒμš”?'",
1756
+ key="user_input",
1757
+ label_visibility="collapsed", # 화면에선 μˆ¨κΉ€
1758
+ )
1759
+ user_input_key = "last_user_input"
1760
+ select_keys = ["intent_chip_selected", "region_chip_selected", "emotion_chip_selected", "theme_chip_selected"]
1761
+
1762
+ # 1-1) β€œμ§„μ§œ μƒˆλ‘œ μž…λ ₯” 감지
1763
+ prev = st.session_state.get(user_input_key, "")
1764
+ if user_input and user_input != prev:
1765
+ for k in select_keys:
1766
+ st.session_state.pop(k, None)
1767
+ st.session_state[user_input_key] = user_input
1768
+ st.session_state["user_input_rendered"] = False
1769
+
1770
+ # step μ΄ˆκΈ°ν™”
1771
+ st.session_state["region_step"] = "recommend"
1772
+ st.rerun()
1773
+
1774
+ # 1-2) μ‚¬μš©μž λ©”μ‹œμ§€ ν•œ 번만 λ Œλ”λ§
1775
+ if user_input and not st.session_state.get("user_input_rendered", False):
1776
+ log_and_render(
1777
+ user_input,
1778
+ sender="user",
1779
+ chat_container = chat_container,
1780
+ key=f"user_input_{user_input}"
1781
+
1782
+ )
1783
+ st.session_state["user_input_rendered"] = True
1784
+
1785
+ if user_input:
1786
+ # 1) μ €λΉ„μš© 단계: μœ„μΉ˜/μ˜λ„ λ¨Όμ €
1787
+ country_filter, city_filter, loc_mode = detect_location_filter(user_input)
1788
+ intent, intent_score = detect_intent(user_input)
1789
+
1790
+ # μ‚¬μ΄λ“œλ°”μ—μ„œ μž„κ³„κ°’μ„ μ“Έ 수 있게 ν–ˆλ‹€λ©΄, μ—†μœΌλ©΄ 0.70 κΈ°λ³Έ
1791
+ threshold = st.session_state.get("intent_threshold", 0.70)
1792
+
1793
+ # 2) λͺ¨λ“œ κ²°μ •: μ§€μ—­ ν™•μ • β†’ intent ν™•μ • β†’ unknown β†’ (κ·Έ μ™Έ) emotion
1794
+ if loc_mode == "region":
1795
+ mode = "region"
1796
+ top_emotions, emotion_groups = [], []
1797
+ elif intent_score >= threshold:
1798
+ mode = "intent"
1799
+ top_emotions, emotion_groups = [], []
1800
+ elif loc_mode == "unknown":
1801
+ mode = "unknown"
1802
+ top_emotions, emotion_groups = [], []
1803
+ else:
1804
+ mode = "emotion"
1805
+ # 3) κ³ λΉ„μš© 단계: 정말 ν•„μš”ν•  λ•Œλ§Œ 감성(BERT) μ‹€ν–‰
1806
+ # with st.spinner("감정 뢄석 쀑..."): # UX μ›ν•˜μ‹œλ©΄ μŠ€ν”Όλ„ˆ μΆ”κ°€
1807
+ top_emotions, emotion_groups = analyze_emotion(user_input)
1808
+
1809
+ # 4) λͺ¨λ“œλ³„ λΆ„κΈ° (ν•„μš”ν•œ κ³„μ‚°λ§Œ μˆ˜ν–‰)
1810
+ if mode == "region":
1811
+ region_ui(
1812
+ travel_df,
1813
+ external_score_df,
1814
+ festival_df,
1815
+ weather_df,
1816
+ package_df,
1817
+ country_filter,
1818
+ city_filter,
1819
+ chat_container,
1820
+ log_and_render
1821
+ )
1822
+ return
1823
+
1824
+ elif mode == "intent":
1825
+ intent_ui(
1826
+ travel_df,
1827
+ external_score_df,
1828
+ festival_df,
1829
+ weather_df,
1830
+ package_df,
1831
+ country_filter,
1832
+ city_filter,
1833
+ chat_container,
1834
+ intent,
1835
+ log_and_render
1836
+ )
1837
+ return
1838
+
1839
+ elif mode == "unknown":
1840
+ unknown_ui(
1841
+ country_filter,
1842
+ city_filter,
1843
+ chat_container,
1844
+ log_and_render
1845
+ )
1846
+ return
1847
+
1848
+ else: # emotion
1849
+ # emotion λͺ¨λ“œμ—μ„œλ§Œ ν…Œλ§ˆ μΆ”μΆœ (λΆˆν•„μš”ν•œ 계산 λ°©μ§€)
1850
+ candidate_themes = extract_themes(
1851
+ emotion_groups,
1852
+ intent,
1853
+ force_mode=False # intent ν™•μ • μΌ€μ΄μŠ€κ°€ μ•„λ‹ˆλΌλ©΄ False
1854
+ )
1855
+ emotion_ui(
1856
+ travel_df,
1857
+ external_score_df,
1858
+ festival_df,
1859
+ weather_df,
1860
+ package_df,
1861
+ country_filter,
1862
+ city_filter,
1863
+ chat_container,
1864
+ candidate_themes,
1865
+ intent,
1866
+ emotion_groups,
1867
+ top_emotions,
1868
+ log_and_render
1869
+ )
1870
+
1871
+ if __name__ == "__main__":
1872
+ main()
1873
+
1874
+
1875
+ #cmd μž…λ ₯-> cd "파일 μœ„μΉ˜ 경둜 볡뢙"
1876
+ #ex(C:\Users\gayoung\Desktop\multi\0514 - project\06 - streamlit ν…ŒμŠ€νŠΈ\test)
1877
+ #cmd μž…λ ₯ -> streamlit run app.py