code-slicer commited on
Commit
73515ef
·
verified ·
1 Parent(s): 38845c7

Delete app.py

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