Jongpal12 commited on
Commit
228ff7f
Β·
verified Β·
1 Parent(s): 8f15a6e

Update app.py

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