code-slicer commited on
Commit
e8b679c
Β·
verified Β·
1 Parent(s): 63b906d

Upload 2 files

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