jeongsoo commited on
Commit
06800a7
Β·
1 Parent(s): e50c25d
Files changed (1) hide show
  1. app.py +578 -436
app.py CHANGED
@@ -14,16 +14,14 @@ import matplotlib.pyplot as plt
14
  import matplotlib.font_manager as fm
15
  from sklearn.manifold import TSNE
16
  import warnings
17
- import gensim # FastText μ‚¬μš©μ„ μœ„ν•œ gensim import
18
- import hashlib # μΊμ‹œ ν‚€ 생성을 μœ„ν•΄ μΆ”κ°€
19
-
20
  warnings.filterwarnings('ignore')
21
 
22
- # --- κΈ°λ³Έ μ„€μ • ---
 
23
  # νŽ˜μ΄μ§€ μ„€μ •
24
  st.set_page_config(
25
- page_title="ν•œκ΅­μ–΄ 단어 의미 λ„€νŠΈμ›Œν¬ μ‹œκ°ν™” (FastText)",
26
- page_icon="🧠", # μ•„μ΄μ½˜ λ³€κ²½
27
  layout="wide"
28
  )
29
 
@@ -32,122 +30,108 @@ DATA_FOLDER = 'data'
32
  UPLOAD_FOLDER = 'uploads'
33
 
34
  # 폴더 생성
35
- if not os.path.exists(DATA_FOLDER):
36
- os.makedirs(DATA_FOLDER)
37
  if not os.path.exists(UPLOAD_FOLDER):
38
  os.makedirs(UPLOAD_FOLDER)
39
 
40
-
41
- # --- FastText λͺ¨λΈ μ„€μ • ---
42
- # !!! μ‚¬μš©μž ν•„μˆ˜ μ„€μ • !!!
43
- # λ‹€μš΄λ‘œλ“œν•œ ν•œκ΅­μ–΄ FastText λͺ¨λΈ 파일(.bin)의 전체 경둜λ₯Ό μ§€μ •ν•˜μ„Έμš”.
44
- # μ˜ˆμ‹œ: "C:/Users/YourUser/Downloads/cc.ko.300.bin" λ˜λŠ” "/home/user/models/cc.ko.300.bin"
45
- # λͺ¨λΈ λ‹€μš΄λ‘œλ“œ: https://fasttext.cc/docs/en/crawl-vectors.html λ“± μ°Έμ‘°
46
- FASTTEXT_MODEL_PATH = "YOUR_PATH_TO/cc.ko.300.bin" # <--- 여기에 μ‹€μ œ 파일 경둜 μž…λ ₯!!!
47
-
48
-
49
- # --- μ„Έμ…˜ μƒνƒœ μ΄ˆκΈ°ν™” ---
50
- if 'fasttext_model' not in st.session_state:
51
- st.session_state.fasttext_model = None # λͺ¨λΈ 객체 μ €μž₯
52
  if 'embeddings_cache' not in st.session_state:
53
- st.session_state.embeddings_cache = {} # μž„λ² λ”© μΊμ‹œλŠ” 단어 λͺ©λ‘+λͺ¨λΈ 기반으둜 재고렀 κ°€λŠ₯ (μ—¬κΈ°μ„  λ‹¨μˆœν™”)
54
  if 'graph_cache' not in st.session_state:
55
  st.session_state.graph_cache = {}
56
  if 'data_files' not in st.session_state:
57
  st.session_state.data_files = {}
58
  if 'selected_files' not in st.session_state:
59
- st.session_state.selected_files = []
60
  if 'threshold' not in st.session_state:
61
- st.session_state.threshold = 0.6 # 의미 κΈ°λ°˜μ΄λ―€λ‘œ μž„κ³„κ°’ κΈ°λ³Έκ°’ μ‘°μ • κ°€λŠ₯
62
- if 'perplexity' not in st.session_state:
63
- st.session_state.perplexity = 30
64
- if 'learning_rate' not in st.session_state:
65
- st.session_state.learning_rate = 'auto'
66
- if 'n_iter' not in st.session_state:
67
- st.session_state.n_iter = 1000
68
  if 'generate_clicked' not in st.session_state:
69
  st.session_state.generate_clicked = False
70
  if 'fig' not in st.session_state:
71
  st.session_state.fig = None
72
 
73
-
74
- # --- FastText λͺ¨λΈ λ‘œλ”© ν•¨μˆ˜ (캐싱 μ‚¬μš©) ---
75
- @st.cache_resource # λͺ¨λΈ κ°μ²΄λŠ” ν¬λ―€λ‘œ λ¦¬μ†ŒμŠ€ 캐싱 μ‚¬μš©
76
- def load_fasttext_model(model_path):
77
- """μ§€μ •λœ κ²½λ‘œμ—μ„œ FastText λͺ¨λΈμ„ λ‘œλ“œν•©λ‹ˆλ‹€."""
78
- if not os.path.exists(model_path):
79
- st.error(f"였λ₯˜: FastText λͺ¨λΈ νŒŒμΌμ„ 찾을 수 μ—†μŠ΅λ‹ˆλ‹€: {model_path}")
80
- st.error("FastText μ›Ήμ‚¬μ΄νŠΈ λ“±μ—μ„œ ν•œκ΅­μ–΄ λͺ¨λΈ(cc.ko.300.bin μΆ”μ²œ)을 λ‹€μš΄λ‘œλ“œν•˜κ³  μ½”λ“œ μƒλ‹¨μ˜ `FASTTEXT_MODEL_PATH` λ³€μˆ˜λ₯Ό μ •ν™•νžˆ μ§€μ •ν•΄μ£Όμ„Έμš”.")
81
- return None
82
- try:
83
- st.info(f"FastText λͺ¨λΈ λ‘œλ”© 쀑... ({os.path.basename(model_path)}) λͺ¨λΈ 크기에 따라 μ‹œκ°„μ΄ 걸릴 수 μžˆμŠ΅λ‹ˆλ‹€.")
84
- # .bin 파일 λ‘œλ“œλ₯Ό μœ„ν•΄ load_facebook_model μ‚¬μš©
85
- model = gensim.models.fasttext.load_facebook_model(model_path)
86
- st.info("FastText λͺ¨λΈ λ‘œλ”© μ™„λ£Œ.")
87
- return model
88
- except Exception as e:
89
- st.error(f"FastText λͺ¨λΈ λ‘œλ”© 쀑 였λ₯˜ λ°œμƒ: {e}")
90
- return None
91
 
92
  # --- ν•œκΈ€ 폰트 μ„€μ • ν•¨μˆ˜ ---
93
  def set_korean_font():
94
- """ μš΄μ˜μ²΄μ œμ— λ§žλŠ” ν•œκΈ€ 폰트λ₯Ό μ„€μ •ν•˜κ³  Plotly용 폰트 이름을 λ°˜ν™˜ν•©λ‹ˆλ‹€. """
 
 
 
95
  system_name = platform.system()
96
- plotly_font_name = 'sans-serif' # κΈ°λ³Έκ°’
97
-
98
- try:
99
- if system_name == "Windows":
100
- font_name = "Malgun Gothic"
101
- plotly_font_name = "Malgun Gothic"
102
- elif system_name == "Darwin": # MacOS
103
- font_name = "AppleGothic"
104
- plotly_font_name = "AppleGothic"
105
- elif system_name == "Linux":
106
- # μ‹œμŠ€ν…œμ—μ„œ Nanum 폰트 μ°ΎκΈ° μ‹œλ„
107
- font_path = None
108
- possible_paths = [
109
- "/usr/share/fonts/truetype/nanum/NanumGothic.ttf",
110
- "/usr/share/fonts/nanum/NanumGothic.ttf",
111
- # λ‹€λ₯Έ 경둜 μΆ”κ°€ κ°€λŠ₯
112
- ]
113
- for path in possible_paths:
114
- if os.path.exists(path):
115
- font_path = path
116
- break
117
-
118
- if font_path:
119
- fm.fontManager.addfont(font_path)
120
- prop = fm.FontProperties(fname=font_path)
121
- font_name = prop.get_name()
122
- plotly_font_name = font_name # PlotlyλŠ” 이름 μ‚¬μš©
123
- else: # μ‹œμŠ€ν…œ 폰트 λ§€λ‹ˆμ €μ—μ„œ 검색
124
  available_fonts = [f.name for f in fm.fontManager.ttflist]
125
  nanum_fonts = [name for name in available_fonts if 'Nanum' in name]
126
  if nanum_fonts:
127
  font_name = nanum_fonts[0]
128
- plotly_font_name = font_name
 
129
  else:
130
- font_name = None # μ°ΎκΈ° μ‹€νŒ¨
131
- else:
132
- font_name = None
 
 
 
 
 
 
 
 
 
 
133
 
134
- # Matplotlib μ„€μ • 적용
135
- if font_name:
 
 
 
 
 
 
 
 
 
136
  plt.rc('font', family=font_name)
137
  plt.rc('axes', unicode_minus=False)
138
  print(f"Matplotlib font set to: {font_name}")
139
- else:
140
- print("Suitable Korean font not found for Matplotlib. Using default.")
141
  plt.rcdefaults()
142
  plt.rc('axes', unicode_minus=False)
143
-
144
- except Exception as e:
145
- print(f"Error setting Korean font: {e}")
146
  plt.rcdefaults()
147
  plt.rc('axes', unicode_minus=False)
148
 
 
 
149
  print(f"Plotly font name to use: {plotly_font_name}")
150
- return plotly_font_name
 
 
151
 
152
  # --- 데이터 λ‘œλ“œ ν•¨μˆ˜ ---
153
  def load_words_from_json(filepath):
@@ -155,9 +139,11 @@ def load_words_from_json(filepath):
155
  try:
156
  with open(filepath, 'r', encoding='utf-8') as f:
157
  data = json.load(f)
 
158
  if isinstance(data, list):
159
- words = [item.get('word', '') for item in data if isinstance(item, dict) and item.get('word')]
160
- words = [word for word in words if word] # 빈 λ¬Έμžμ—΄ 제거
 
161
  if not words:
162
  st.warning(f"κ²½κ³ : 파일 '{os.path.basename(filepath)}'μ—μ„œ 'word' ν‚€λ₯Ό κ°€μ§„ μœ νš¨ν•œ 데이터λ₯Ό 찾을 수 μ—†μŠ΅λ‹ˆλ‹€.")
163
  return None
@@ -175,327 +161,428 @@ def load_words_from_json(filepath):
175
  st.error(f"'{os.path.basename(filepath)}' 데이터 λ‘œλ”© 쀑 였λ₯˜ λ°œμƒ: {e}")
176
  return None
177
 
 
178
  def scan_data_files():
179
- """데이터 폴더 및 μ—…λ‘œλ“œ ν΄λ”μ—μ„œ μ‚¬μš© κ°€λŠ₯ν•œ JSON νŒŒμΌμ„ μŠ€μΊ”ν•©λ‹ˆλ‹€."""
180
  data_files = {}
181
- # κΈ°λ³Έ 데이터 폴더
182
  try:
183
  for file_path in glob.glob(os.path.join(DATA_FOLDER, '*.json')):
184
- file_id = f"default_{os.path.basename(file_path)}"
185
  file_name = os.path.basename(file_path)
186
  words = load_words_from_json(file_path)
187
- if words:
188
- data_files[file_id] = {'path': file_path, 'name': file_name, 'word_count': len(words), 'type': 'default', 'sample_words': words[:5]}
 
 
 
 
 
 
189
  except Exception as e:
190
  st.error(f"κΈ°λ³Έ 데이터 폴더 μŠ€μΊ” 쀑 였λ₯˜: {e}")
191
- # μ—…λ‘œλ“œ 폴더
 
192
  try:
193
  for file_path in glob.glob(os.path.join(UPLOAD_FOLDER, '*.json')):
194
- file_id = f"uploaded_{os.path.basename(file_path)}"
195
  file_name = os.path.basename(file_path)
196
  words = load_words_from_json(file_path)
197
- if words:
198
- data_files[file_id] = {'path': file_path, 'name': file_name, 'word_count': len(words), 'type': 'uploaded', 'sample_words': words[:5]}
 
 
 
 
 
 
199
  except Exception as e:
200
  st.error(f"μ—…λ‘œλ“œ 폴더 μŠ€μΊ” 쀑 였λ₯˜: {e}")
 
201
  return data_files
202
 
203
- def merge_word_lists(file_ids, current_data_files):
 
204
  """μ„ νƒλœ νŒŒμΌλ“€μ—μ„œ 단어λ₯Ό λ‘œλ“œν•˜κ³  쀑볡 μ œκ±°ν•˜μ—¬ λ³‘ν•©ν•©λ‹ˆλ‹€."""
205
- all_words = set() # 쀑볡 제거λ₯Ό μœ„ν•΄ set μ‚¬μš©
206
  if not file_ids:
207
  return []
208
 
 
 
 
209
  for file_id in file_ids:
210
  if file_id in current_data_files:
211
  file_path = current_data_files[file_id]['path']
212
  words = load_words_from_json(file_path)
213
  if words:
214
- all_words.update(words) # set에 μΆ”κ°€
215
  else:
216
- st.warning(f"μ„ νƒλœ 파일 ID '{file_id}'λ₯Ό 찾을 수 μ—†μŠ΅λ‹ˆλ‹€. λͺ©λ‘μ„ μƒˆλ‘œκ³ μΉ¨ν•©λ‹ˆλ‹€.")
217
- # 파일 λͺ©λ‘ μž¬μŠ€μΊ” λ‘œμ§μ€ λ³΅μž‘ν•΄μ§ˆ 수 μžˆμœΌλ―€λ‘œ μ—¬κΈ°μ„œλŠ” 경고만 ν‘œμ‹œ
218
- # μ •λ ¬λœ 리슀트둜 λ°˜ν™˜
219
- unique_words = sorted(list(all_words))
220
- return unique_words
 
 
 
221
 
222
- # --- 단어 μž„λ² λ”© ν•¨μˆ˜ (FastText μ‚¬μš©) ---
223
- def encode_words_fasttext(words, normalize=True):
224
- """FastText λͺ¨λΈμ„ μ‚¬μš©ν•˜μ—¬ 단어 λͺ©λ‘μ„ 의미 μž„λ² λ”©μœΌλ‘œ λ³€ν™˜ν•©λ‹ˆλ‹€."""
225
- model = st.session_state.get('fasttext_model')
226
 
227
- if model is None:
228
- st.error("FastText λͺ¨λΈμ΄ λ‘œλ“œλ˜μ§€ μ•Šμ•„ μž„λ² λ”©μ„ 생성할 수 μ—†μŠ΅λ‹ˆλ‹€.")
229
- return None
230
 
 
 
 
231
  if not words:
232
  return np.array([])
233
 
234
  embeddings = []
235
- oov_count = 0
236
- vector_size = model.vector_size
 
 
237
 
238
- with st.spinner(f"단어 {len(words)}κ°œμ— λŒ€ν•œ 의미 μž„λ² λ”© 생성 쀑 (FastText)..."):
239
- for word in words:
240
- try:
241
- vector = model.wv[word]
242
- if np.all(vector == 0):
243
- oov_count += 1
244
- if normalize:
245
- norm = np.linalg.norm(vector)
246
- vector = vector / norm if norm > 0 else np.zeros(vector_size)
247
- embeddings.append(vector)
248
- except Exception as e:
249
- st.warning(f"단어 '{word}' 처리 쀑 였λ₯˜ λ°œμƒ (ν˜Ήμ€ OOV): {e}. 0λ²‘ν„°λ‘œ λŒ€μ²΄ν•©λ‹ˆλ‹€.")
250
- embeddings.append(np.zeros(vector_size))
251
- oov_count += 1
252
-
253
- if oov_count > 0:
254
- st.warning(f"총 {len(words)}개 단어 쀑 {oov_count}κ°œμ— λŒ€ν•΄ 유효 벑터λ₯Ό μ–»μ§€ λͺ»ν–ˆμŠ΅λ‹ˆλ‹€(OOV λ“±).")
255
-
256
- result_embeddings = np.array(embeddings)
257
-
258
- if result_embeddings.size == 0 and len(words) > 0:
259
- st.error("μž„λ² λ”© 생성 κ²°κ³Όκ°€ λΉ„μ–΄ μžˆμŠ΅λ‹ˆλ‹€.")
260
- return None
261
- elif result_embeddings.shape[0] != len(words):
262
- st.error(f"μž…λ ₯ 단어 수({len(words)})와 μƒμ„±λœ μž„λ² λ”© 수({result_embeddings.shape[0]}) 뢈일치.")
263
- return None
264
-
265
- return result_embeddings
266
-
267
- # --- κ·Έλž˜ν”„ 생성 ν•¨μˆ˜ ---
268
- def generate_graph(file_ids, similarity_threshold, perplexity, learning_rate, n_iter):
269
- """ 의미 μœ μ‚¬μ„± 기반 3D κ·Έλž˜ν”„λ₯Ό μƒμ„±ν•©λ‹ˆλ‹€. """
270
- # κ·Έλž˜ν”„ μΊμ‹œ ν‚€ 생성 (파일 ID, μž„κ³„κ°’, t-SNE νŒŒλΌλ―Έν„° 포함)
271
- param_str = f"t{similarity_threshold}_p{perplexity}_lr{learning_rate}_i{n_iter}"
272
- sorted_fids = "-".join(sorted(file_ids))
273
- # 단어 λͺ©λ‘ 자체λ₯Ό ν•΄μ‹œν•˜μ—¬ μΊμ‹œ 킀에 포함 (더 μ •ν™•ν•˜μ§€λ§Œ 느릴 수 있음)
274
- # word_list_for_key = merge_word_lists(file_ids, st.session_state.data_files)
275
- # word_hash = hashlib.sha256(str(word_list_for_key).encode()).hexdigest()[:8]
276
- # cache_key = f"{sorted_fids}_{word_hash}_{param_str}_fasttext"
277
- cache_key = f"{sorted_fids}_{param_str}_fasttext" # 파일 ID 기반 μΊμ‹œ
278
 
279
- if cache_key in st.session_state.graph_cache:
280
- st.info("μΊμ‹œλœ κ·Έλž˜ν”„λ₯Ό μ‚¬μš©ν•©λ‹ˆλ‹€.")
281
- return st.session_state.graph_cache[cache_key]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
282
 
283
- # --- ν•„μš” 데이터 λ‘œλ“œ 및 검증 ---
 
 
 
 
284
  if not file_ids:
285
  st.error("κ·Έλž˜ν”„λ₯Ό 생성할 파일이 μ„ νƒλ˜μ§€ μ•Šμ•˜μŠ΅λ‹ˆλ‹€.")
286
  return None
287
- if st.session_state.get('fasttext_model') is None:
288
- st.error("FastText λͺ¨λΈμ΄ λ‘œλ“œλ˜μ§€ μ•Šμ•„ κ·Έλž˜ν”„ 생성을 μ§„ν–‰ν•  수 μ—†μŠ΅λ‹ˆλ‹€.")
289
- return None
290
 
291
- plotly_font = set_korean_font() # ν•œκΈ€ 폰트 μ„€μ •
292
- word_list = merge_word_lists(file_ids, st.session_state.data_files) # 단어 λͺ©λ‘ 병합
 
 
 
 
 
 
 
 
 
293
 
294
  if not word_list:
295
  st.error("μ„ νƒλœ νŒŒμΌμ—μ„œ μœ νš¨ν•œ 단어λ₯Ό λ‘œλ“œν•  수 μ—†μŠ΅λ‹ˆλ‹€.")
296
  return None
 
297
  if len(word_list) < 2:
298
  st.warning("κ·Έλž˜ν”„λ₯Ό μƒμ„±ν•˜λ €λ©΄ μ΅œμ†Œ 2개 μ΄μƒμ˜ 고유 단어가 ν•„μš”ν•©λ‹ˆλ‹€.")
299
  return None
300
 
301
- # --- μž„λ² λ”© 생성 ---
302
- embeddings = encode_words_fasttext(word_list, normalize=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
303
  if embeddings is None or embeddings.shape[0] == 0 or embeddings.shape[1] == 0:
304
- st.error("μœ νš¨ν•œ 단어 μž„λ² λ”© 생성 μ‹€νŒ¨.")
305
  return None
306
 
307
- # --- 차원 μΆ•μ†Œ (t-SNE) ---
308
  embeddings_3d = None
309
- n_samples = embeddings.shape[0]
310
- with st.spinner(f'단어 {n_samples}개 μ’Œν‘œ 계산 쀑 (t-SNE)...'):
311
- effective_perplexity = min(perplexity, max(5, n_samples - 1))
312
- if effective_perplexity != perplexity:
313
- st.warning(f"Perplexityκ°€ μƒ˜ν”Œ μˆ˜μ— 맞게 {effective_perplexity}(으)둜 μ‘°μ •λ˜μ—ˆμŠ΅λ‹ˆλ‹€.")
314
- effective_lr = learning_rate if isinstance(learning_rate, (int, float)) else 200.0 if learning_rate == 'auto' else learning_rate
315
- effective_iter = n_iter
316
-
317
- if n_samples <= 3:
318
- st.warning(f"단어 μˆ˜κ°€ {n_samples}개둜 적어 PCAλ₯Ό μ‚¬μš©ν•©λ‹ˆλ‹€.")
319
- from sklearn.decomposition import PCA
320
- pca = PCA(n_components=min(3, n_samples), random_state=42)
321
- embeddings_3d_pca = pca.fit_transform(embeddings)
322
- embeddings_3d = np.zeros((n_samples, 3))
323
- embeddings_3d[:, :embeddings_3d_pca.shape[1]] = embeddings_3d_pca
 
 
 
324
  else:
325
- try:
326
- tsne = TSNE(n_components=3, random_state=42,
327
- perplexity=effective_perplexity,
328
- n_iter=effective_iter,
329
- init='pca',
330
- learning_rate=effective_lr,
331
- n_jobs=-1)
332
- embeddings_3d = tsne.fit_transform(embeddings)
333
- except Exception as e:
334
- st.error(f"t-SNE 였λ₯˜: {e}. PCA둜 λŒ€μ²΄ν•©λ‹ˆλ‹€.")
335
- from sklearn.decomposition import PCA
336
- pca = PCA(n_components=3, random_state=42)
337
- embeddings_3d = pca.fit_transform(embeddings) # PCA둜 μž¬μ‹œλ„
338
-
339
- if embeddings_3d is None or embeddings_3d.shape[0] != len(word_list):
340
- st.error("단어 3D μ’Œν‘œ 생성 μ‹€νŒ¨.")
 
 
 
341
  return None
342
 
343
- # --- μœ μ‚¬λ„ 계산 및 κ·Έλž˜ν”„ ꡬ성 ---
344
  edges = []
345
  edge_weights = []
346
- with st.spinner('단어 κ°„ 의미 μœ μ‚¬λ„ 계산 및 μ—°κ²° 생성 쀑...'):
347
- try:
348
- similarity_matrix = cosine_similarity(embeddings)
349
- for i in range(n_samples):
350
- for j in range(i + 1, n_samples):
351
- similarity = similarity_matrix[i, j]
352
- if not np.isnan(similarity) and similarity >= similarity_threshold:
353
- edges.append((word_list[i], word_list[j]))
354
- edge_weights.append(similarity)
355
- except Exception as e:
356
- st.error(f"μœ μ‚¬λ„ 계산 쀑 였λ₯˜ λ°œμƒ: {e}")
357
- return None
358
-
359
- # --- NetworkX κ·Έλž˜ν”„ 생성 ---
360
  G = nx.Graph()
361
- valid_nodes_count = 0
362
  for i, word in enumerate(word_list):
363
- if i < embeddings_3d.shape[0]: # μ’Œν‘œκ°€ μƒμ„±λœ λ…Έλ“œλ§Œ μΆ”κ°€
364
- G.add_node(word, pos=(embeddings_3d[i, 0], embeddings_3d[i, 1], embeddings_3d[i, 2]))
365
- valid_nodes_count += 1
366
- else:
367
- st.warning(f"'{word}' 단어 μ’Œν‘œ λˆ„λ½.") # λˆ„λ½ κ²½κ³ 
368
 
369
- if valid_nodes_count != len(word_list):
370
- st.warning(f"{len(word_list)-valid_nodes_count}개 단어 λ…Έλ“œ 생성 μ‹€νŒ¨.")
371
-
372
- valid_edges_count = 0
373
  for edge, weight in zip(edges, edge_weights):
374
- if G.has_node(edge[0]) and G.has_node(edge[1]): # λ…Έλ“œκ°€ μžˆλŠ”μ§€ 확인 ν›„ μ—£μ§€ μΆ”κ°€
 
375
  G.add_edge(edge[0], edge[1], weight=weight)
376
- valid_edges_count += 1
377
 
378
- # --- Plotly μ‹œκ°ν™” 객체 생성 ---
379
  edge_x, edge_y, edge_z = [], [], []
380
  if G.number_of_edges() > 0:
381
  for edge in G.edges():
382
  try:
383
  pos0 = G.nodes[edge[0]]['pos']
384
  pos1 = G.nodes[edge[1]]['pos']
385
- edge_x.extend([pos0[0], pos1[0], None])
386
  edge_y.extend([pos0[1], pos1[1], None])
387
  edge_z.extend([pos0[2], pos1[2], None])
388
  except KeyError as e:
389
- st.warning(f"μ—£μ§€ {edge} 생성 쀑 λ…Έλ“œ μœ„μΉ˜ 였λ₯˜: {e}")
390
- continue
391
-
392
- edge_trace = go.Scatter3d(x=edge_x, y=edge_y, z=edge_z, mode='lines', line=dict(width=1, color='#888'), hoverinfo='none')
 
 
 
 
 
 
393
 
394
- node_x, node_y, node_z, node_text, node_hover_text, node_sizes = [], [], [], [], [], []
395
- if G.number_of_nodes() > 0:
396
- degrees = np.array([G.degree(node) for node in G.nodes()])
397
- # 둜그 μŠ€μΌ€μΌλ§ + 크기 μ œν•œ
398
- raw_sizes = np.log1p(degrees) * 3 + 6
399
- node_sizes_list = np.clip(raw_sizes, 5, 20).tolist()
400
 
401
- for i, node in enumerate(G.nodes()):
402
- try:
403
- pos = G.nodes[node]['pos']
404
- degree = G.degree(node)
405
- node_x.append(pos[0])
406
- node_y.append(pos[1])
407
- node_z.append(pos[2])
408
- node_text.append(node)
409
- node_hover_text.append(f'{node}<br>μ—°κ²° 수: {degree}')
410
- # node_sizes λ¦¬μŠ€νŠΈλŠ” 이미 μœ„μ—μ„œ 계산됨
411
- except KeyError:
412
- st.warning(f"λ…Έλ“œ '{node}' μœ„μΉ˜ 정보 였λ₯˜.")
413
- continue # ν•΄λ‹Ή λ…Έλ“œ κ±΄λ„ˆλ›°κΈ°
414
-
415
- node_trace = go.Scatter3d(
416
- x=node_x, y=node_y, z=node_z,
417
- mode='markers+text',
418
- text=node_text,
419
- hovertext=node_hover_text,
420
- hoverinfo='text',
421
- textposition='top center',
422
- textfont=dict(size=10, color='black', family=plotly_font),
423
- marker=dict(
424
- size=node_sizes_list if node_sizes_list else 5, # κ³„μ‚°λœ 크기 μ‚¬μš©
425
- color=node_z, # Zκ°’μœΌλ‘œ 색상 λ§€ν•‘
426
- colorscale='Viridis',
427
- opacity=0.9,
428
- colorbar=dict(thickness=15, title='Node Depth (Z)', xanchor='left', titleside='right')
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
429
  )
430
- )
 
 
431
 
432
- # --- λ ˆμ΄μ•„μ›ƒ μ„€μ • 및 Figure 생성 ---
433
- current_data_files = st.session_state.get('data_files', {})
434
- file_names_used = [current_data_files[fid]['name'] for fid in file_ids if fid in current_data_files]
 
 
435
  file_info_str = ", ".join(file_names_used) if file_names_used else "μ•Œ 수 μ—†μŒ"
436
 
 
 
437
  layout = go.Layout(
438
  title=dict(
439
- text=f'<b>μ–΄νœ˜ 의미 μœ μ‚¬μ„± 기반 3D κ·Έλž˜ν”„ (FastText)</b><br>Threshold: {similarity_threshold:.2f} | 데이터: {file_info_str}',
440
  font=dict(size=16, family=plotly_font),
441
- x=0.5, xanchor='center'
 
442
  ),
443
- showlegend=False,
444
- margin=dict(l=10, r=10, b=10, t=80),
445
  scene=dict(
446
- xaxis=dict(title='TSNE-1', showticklabels=False, backgroundcolor="rgb(230, 230, 230)", gridcolor="white", zerolinecolor="white"),
447
- yaxis=dict(title='TSNE-2', showticklabels=False, backgroundcolor="rgb(230, 230, 230)", gridcolor="white", zerolinecolor="white"),
448
- zaxis=dict(title='TSNE-3', showticklabels=False, backgroundcolor="rgb(230, 230, 230)", gridcolor="white", zerolinecolor="white"),
449
- aspectratio=dict(x=1, y=1, z=0.8),
450
- camera=dict(eye=dict(x=1.2, y=1.2, z=0.8))
 
 
 
 
 
 
 
 
 
 
 
451
  ),
 
452
  hovermode='closest'
453
  )
454
 
 
455
  fig = go.Figure(data=[edge_trace, node_trace], layout=layout)
456
 
457
- # κ·Έλž˜ν”„ μΊμ‹œ μ €μž₯
458
  st.session_state.graph_cache[cache_key] = fig
459
 
460
  return fig
461
 
462
- # --- 파일 처리 ν•¨μˆ˜ ---
463
  def handle_uploaded_file(uploaded_file):
464
- """ μ—…λ‘œλ“œλœ νŒŒμΌμ„ μ²˜λ¦¬ν•˜κ³  data_files λͺ©λ‘μ„ κ°±μ‹ ν•©λ‹ˆλ‹€. """
465
  if uploaded_file is not None:
466
- unique_id = str(uuid.uuid4())
467
- file_name = f"{unique_id}_{uploaded_file.name}"
 
 
 
 
468
  file_path = os.path.join(UPLOAD_FOLDER, file_name)
469
 
470
  try:
 
471
  with open(file_path, 'wb') as f:
472
  f.write(uploaded_file.getbuffer())
473
- st.info(f"파일 '{uploaded_file.name}' μ €μž₯ μ™„λ£Œ. λ‚΄μš© 검증 쀑...")
474
 
 
475
  words = load_words_from_json(file_path)
476
- if words is None or not words :
477
- os.remove(file_path)
478
- st.error(f"μ—…λ‘œλ“œλœ 파일 '{uploaded_file.name}'μ—μ„œ μœ νš¨ν•œ 'word' 데이터λ₯Ό 찾을 수 μ—†μŠ΅λ‹ˆλ‹€. μ‚­μ œλ˜μ—ˆμŠ΅λ‹ˆλ‹€.")
479
- return None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
480
  else:
481
- st.success(f"파일 '{uploaded_file.name}' 검증 μ™„λ£Œ ({len(words)} 단어).")
482
- # 데이터 파일 λͺ©λ‘ μ¦‰μ‹œ κ°±μ‹ 
483
- st.session_state.data_files = scan_data_files()
484
- new_file_id = f"uploaded_{file_name}"
485
- return new_file_id
486
  except Exception as e:
487
- st.error(f"파일 μ—…λ‘œλ“œ 처리 쀑 였λ₯˜: {e}")
488
- if os.path.exists(file_path): os.remove(file_path) # 였λ₯˜ μ‹œ 파일 μ‚­μ œ
489
- return None
 
 
 
 
 
 
490
 
491
  def delete_file(file_id):
492
- """ μ—…λ‘œλ“œλœ νŒŒμΌμ„ μ‚­μ œν•˜κ³  κ΄€λ ¨ μΊμ‹œλ₯Ό μ •λ¦¬ν•©λ‹ˆλ‹€. """
493
- current_data_files = st.session_state.get('data_files', {})
494
- if file_id not in current_data_files:
495
  st.error('μ‚­μ œν•  νŒŒμΌμ„ 찾을 수 μ—†μŠ΅λ‹ˆλ‹€.')
496
  return False
497
 
498
- file_info = current_data_files[file_id]
 
 
499
  if file_info.get('type') != 'uploaded':
500
  st.error('κΈ°λ³Έ 데이터 νŒŒμΌμ€ μ‚­μ œν•  수 μ—†μŠ΅λ‹ˆλ‹€.')
501
  return False
@@ -503,218 +590,235 @@ def delete_file(file_id):
503
  file_path = file_info.get('path')
504
  file_name = file_info.get('name', 'μ•Œ 수 μ—†μŒ')
505
 
 
 
 
 
506
  try:
507
- if file_path and os.path.exists(file_path):
 
508
  os.remove(file_path)
509
- st.info(f"파일 '{file_name}' μ‚­μ œ μ™„λ£Œ.")
510
  else:
511
- st.warning(f"파일 '{file_name}'({file_path})을 찾을 수 μ—†κ±°λ‚˜ 이미 μ‚­μ œλ˜μ—ˆμŠ΅λ‹ˆλ‹€.")
512
 
513
- # μ„Έμ…˜ μƒνƒœ μ—…λ°μ΄νŠΈ
514
  del st.session_state.data_files[file_id]
515
- if file_id in st.session_state.selected_files:
516
- st.session_state.selected_files.remove(file_id)
517
 
518
- # κ΄€λ ¨ κ·Έλž˜ν”„ μΊμ‹œ μ‚­μ œ (킀에 file_idκ°€ ν¬ν•¨λœ ν•­λͺ©)
519
- keys_to_remove = [k for k in st.session_state.graph_cache if file_id in k.split('_')[0]] # ν‚€ ν˜•μ‹ κ°€μ •
520
- for key in keys_to_remove:
521
  del st.session_state.graph_cache[key]
522
- if keys_to_remove: st.info(f"{len(keys_to_remove)}개 κ΄€λ ¨ κ·Έλž˜ν”„ μΊμ‹œ μ‚­μ œ.")
523
 
524
- st.success(f"'{file_name}' κ΄€λ ¨ 정보 및 μΊμ‹œ μ‚­μ œ μ™„λ£Œ.")
 
 
 
 
 
 
 
 
525
  return True
526
 
527
  except Exception as e:
528
  st.error(f"파일 μ‚­μ œ 쀑 였λ₯˜ λ°œμƒ: {e}")
529
  return False
530
 
531
- # --- μΊμ‹œ μ΄ˆκΈ°ν™” ν•¨μˆ˜ ---
532
  def clear_cache():
533
- """ κ·Έλž˜ν”„ μΊμ‹œλ₯Ό μ΄ˆκΈ°ν™”ν•©λ‹ˆλ‹€. """
534
  st.session_state.graph_cache = {}
535
- # st.session_state.embeddings_cache = {} # μž„λ² λ”© μΊμ‹œλŠ” ν˜„μž¬ μ‚¬μš© μ•ˆ 함
536
- st.session_state.fig = None
537
- st.success('κ·Έλž˜ν”„ μΊμ‹œκ°€ μ΄ˆκΈ°ν™”λ˜μ—ˆμŠ΅λ‹ˆλ‹€.')
538
- st.rerun() # UI κ°±μ‹ 
539
-
540
 
541
- # ==============================================================================
542
- # --- Streamlit μ•± μ‹€ν–‰ λΆ€λΆ„ ---
543
- # ==============================================================================
544
 
545
- # --- μ•± μ‹œμž‘ μ‹œ μ΄ˆκΈ°ν™” ---
546
- # FastText λͺ¨λΈ λ‘œλ“œ μ‹œλ„
547
- if 'fasttext_model' not in st.session_state or st.session_state.fasttext_model is None:
548
- st.session_state.fasttext_model = load_fasttext_model(FASTTEXT_MODEL_PATH)
549
 
550
- # 데이터 파일 μŠ€μΊ”
551
  if 'data_files' not in st.session_state or not st.session_state.data_files:
552
  st.session_state.data_files = scan_data_files()
553
 
554
  # 타이틀 및 μ†Œκ°œ
555
- st.title('ν•œκ΅­μ–΄ 단어 의미 λ„€νŠΈμ›Œν¬ μ‹œκ°ν™” (FastText 기반)')
556
  st.markdown("""
557
- 이 λ„κ΅¬λŠ” JSON 파일의 단어 λͺ©λ‘μ„ **FastText μž„λ² λ”©**으둜 λ³€ν™˜ν•˜μ—¬ 의미적 μœ μ‚¬μ„±μ„ κ³„μ‚°ν•˜κ³ , κ·Έ 관계λ₯Ό 3D λ„€νŠΈμ›Œν¬ κ·Έλž˜ν”„λ‘œ μ‹œκ°ν™”ν•©λ‹ˆλ‹€.
558
- μœ μ‚¬ν•œ 의미의 단어듀이 μ„œλ‘œ κ°€κΉκ²Œ λ°°μΉ˜λ˜λŠ” κ²½ν–₯을 λ³΄μž…λ‹ˆλ‹€.
559
  """)
560
 
561
- # λͺ¨λΈ λ‘œλ”© μƒνƒœ 확인
562
- if st.session_state.get('fasttext_model') is None:
563
- st.error("FastText λͺ¨λΈ λ‘œλ”© μ‹€νŒ¨. μ½”λ“œ μƒλ‹¨μ˜ `FASTTEXT_MODEL_PATH` 섀정을 ν™•μΈν•˜κ³  앱을 μž¬μ‹€ν–‰ν•΄μ£Όμ„Έμš”.")
564
- st.stop() # λͺ¨λΈ μ—†μœΌλ©΄ μ•± 쀑단
565
-
566
-
567
- # --- μ‚¬μ΄λ“œλ°” ---
568
  st.sidebar.title('βš™οΈ μ„€μ • 및 μ œμ–΄')
569
 
570
- # 1. μœ μ‚¬λ„ μž„κ³„κ°’
571
  threshold = st.sidebar.slider(
572
- 'μœ μ‚¬λ„ μž„κ³„κ°’ (Similarity Threshold)', 0.1, 0.95, st.session_state.threshold, 0.05,
573
- help='이 κ°’ μ΄μƒμœΌλ‘œ μœ μ‚¬ν•œ λ‹¨μ–΄λ§Œ μ—°κ²°ν•©λ‹ˆλ‹€. λ†’μ„μˆ˜λ‘ 연결이 μ—„κ²©ν•΄μ§‘λ‹ˆλ‹€.'
 
 
 
 
574
  )
 
575
  if threshold != st.session_state.threshold:
576
  st.session_state.threshold = threshold
577
- st.session_state.fig = None # μ„€μ • λ³€κ²½ μ‹œ κ·Έλž˜ν”„ μž¬μƒμ„± ν•„μš” μ•Œλ¦Ό
578
- st.session_state.generate_clicked = False
579
-
580
- st.sidebar.divider()
581
-
582
- # 2. t-SNE νŒŒλΌλ―Έν„° (μ‹œκ°ν™” λ―Έμ„Έ μ‘°μ •)
583
- st.sidebar.header("t-SNE νŒŒλΌλ―Έν„° (κ³ κΈ‰)")
584
- perplexity = st.sidebar.slider(
585
- "Perplexity", 5, 50, st.session_state.perplexity, 1,
586
- help="각 점이 κ³ λ €ν•˜λŠ” 이웃 μˆ˜μ™€ κ΄€λ ¨. κ΅°μ§‘ ν˜•νƒœμ— 영ν–₯."
587
- )
588
- learning_rate = st.sidebar.select_slider(
589
- "Learning Rate", options=[10, 50, 100, 200, 500, 1000, 'auto'], value=st.session_state.learning_rate,
590
- help="μ΅œμ ν™” ν•™μŠ΅ 속도. κ΅°μ§‘ κ°„ 거리에 영ν–₯."
591
- )
592
- n_iter = st.sidebar.select_slider(
593
- "Iterations", options=[250, 500, 1000, 2000, 5000], value=st.session_state.n_iter,
594
- help="μ΅œμ ν™” 반볡 횟수. λ†’μ„μˆ˜λ‘ μ•ˆμ •μ μ΄λ‚˜ 였래 κ±Έλ¦Ό."
595
- )
596
- # t-SNE νŒŒλΌλ―Έν„° λ³€κ²½ μ‹œ μƒνƒœ μ—…λ°μ΄νŠΈ 및 κ·Έλž˜ν”„ μ΄ˆκΈ°ν™”
597
- if (perplexity != st.session_state.perplexity or
598
- learning_rate != st.session_state.learning_rate or
599
- n_iter != st.session_state.n_iter):
600
- st.session_state.perplexity = perplexity
601
- st.session_state.learning_rate = learning_rate
602
- st.session_state.n_iter = n_iter
603
- st.session_state.fig = None
604
- st.session_state.generate_clicked = False
605
 
606
  st.sidebar.divider()
607
 
608
- # 3. 파일 μ—…λ‘œλ“œ
609
  st.sidebar.header('πŸ“„ 파일 μ—…λ‘œλ“œ')
610
  uploaded_file = st.sidebar.file_uploader(
611
- "JSON 파일 μ—…λ‘œλ“œ (ν˜•μ‹: [{'word': '단어1'}, ...])", type=['json']
 
 
612
  )
 
613
  if uploaded_file is not None:
 
 
614
  with st.spinner("μ—…λ‘œλ“œλœ 파일 처리 쀑..."):
615
  new_file_id = handle_uploaded_file(uploaded_file)
616
  if new_file_id:
617
- st.sidebar.success(f"파일 '{uploaded_file.name}' μ—…λ‘œλ“œ μ™„λ£Œ!")
618
- # μƒˆλ‘œ μ—…λ‘œλ“œλœ νŒŒμΌμ„ μžλ™μœΌλ‘œ 선택 λͺ©λ‘μ— μΆ”κ°€ 및 선택
619
  if new_file_id not in st.session_state.selected_files:
620
  st.session_state.selected_files.append(new_file_id)
621
- st.rerun() # UI μ¦‰μ‹œ κ°±μ‹ 
 
 
 
 
 
 
622
 
623
  st.sidebar.divider()
624
 
625
- # 4. 파일 선택
626
  st.sidebar.header('πŸ—‚οΈ 데이터 파일 선택')
627
- current_data_files = st.session_state.get('data_files', {})
628
- if current_data_files:
629
- st.sidebar.markdown("**μ‚¬μš©ν•  νŒŒμΌμ„ μ„ νƒν•˜μ„Έμš”:**")
 
 
 
630
  selected_files_temp = []
631
- sorted_file_ids = sorted(current_data_files.keys(), key=lambda fid: current_data_files[fid]['name'])
 
 
632
 
 
633
  for file_id in sorted_file_ids:
634
- if file_id not in current_data_files: continue # μ•ˆμ „μž₯치
635
- file_info = current_data_files[file_id]
 
636
  file_label = f"{file_info['name']} ({file_info['word_count']} 단어)"
637
  file_type_tag = "[κΈ°λ³Έ]" if file_info['type'] == 'default' else "[μ—…λ‘œλ“œ]"
638
  label_full = f"{file_label} {file_type_tag}"
 
 
639
  is_selected = file_id in st.session_state.selected_files
640
 
641
- # μ²΄ν¬λ°•μŠ€ μƒνƒœ λ³€κ²½ 감지
642
- if st.sidebar.checkbox(label_full, value=is_selected, key=f"cb_{file_id}"):
 
 
 
643
  selected_files_temp.append(file_id)
644
- # 파일 정보 ν™•μž₯ μ„Ήμ…˜
 
645
  with st.sidebar.expander("파일 정보 보기", expanded=False):
646
- st.markdown(f"**μƒ˜ν”Œ:** `{'`, `'.join(file_info['sample_words'])}`")
647
  if file_info['type'] == 'uploaded':
648
- if st.button('πŸ—‘οΈ 이 파일 μ‚­μ œ', key=f"del_{file_id}", help=f"'{file_info['name']}' μ‚­μ œ"):
649
- if delete_file(file_id):
650
- st.rerun() # μ‚­μ œ 성곡 μ‹œ UI κ°±μ‹ 
651
-
652
- # 선택 μƒνƒœ λ³€κ²½ μ‹œ μ„Έμ…˜ μ—…λ°μ΄νŠΈ 및 κ·Έλž˜ν”„ μ΄ˆκΈ°ν™”
 
 
 
 
 
 
 
 
 
 
 
653
  if sorted(selected_files_temp) != sorted(st.session_state.selected_files):
654
  st.session_state.selected_files = selected_files_temp
655
- st.session_state.fig = None
656
- st.session_state.generate_clicked = False
657
- st.rerun() # 선택 λ³€κ²½ μ‹œ μ¦‰μ‹œ UI 반영
 
658
 
659
  st.sidebar.divider()
660
 
661
- # 5. κ·Έλž˜ν”„ 생성 λ²„νŠΌ
 
662
  if st.session_state.selected_files:
663
  if st.sidebar.button('πŸ“Š κ·Έλž˜ν”„ 생성/μ—…λ°μ΄νŠΈ', key='generate_button', type="primary"):
664
- st.session_state.generate_clicked = True
665
- # λ²„νŠΌ 클릭 μ‹œ μžλ™μœΌλ‘œ rerun λ˜λ―€λ‘œ μ—¬κΈ°μ„œλŠ” ν”Œλž˜κ·Έλ§Œ μ„€μ •
 
 
 
 
 
 
666
  else:
667
- st.sidebar.warning('κ·Έλž˜ν”„λ₯Ό 생성할 νŒŒμΌμ„ 1개 이상 μ„ νƒν•΄μ£Όμ„Έμš”.')
668
 
669
  else:
670
- st.sidebar.info('μ‚¬μš© κ°€λŠ₯ν•œ 데이터 파일이 μ—†μŠ΅λ‹ˆλ‹€. νŒŒμΌμ„ μ—…λ‘œλ“œν•˜κ±°λ‚˜ `data` 폴더에 JSON νŒŒμΌμ„ μΆ”κ°€ν•˜μ„Έμš”.')
671
 
672
- st.sidebar.divider()
673
 
674
- # 6. μΊμ‹œ μ΄ˆκΈ°ν™” λ²„νŠΌ
675
  if st.sidebar.button('πŸ”„ μΊμ‹œ μ΄ˆκΈ°ν™”', key='clear_cache_button'):
676
  clear_cache()
677
 
678
-
679
  # --- 메인 μ½˜ν…μΈ  μ˜μ—­ ---
680
  st.header("πŸ“ˆ 3D 단어 λ„€νŠΈμ›Œν¬ μ‹œκ°ν™”")
681
 
682
  # κ·Έλž˜ν”„ ν‘œμ‹œ 둜직
 
 
 
 
683
  if st.session_state.selected_files:
684
- # κ·Έλž˜ν”„λ₯Ό 생성해야 ν•˜λŠ” 쑰건 확인
685
  should_generate_graph = st.session_state.generate_clicked or \
686
- (st.session_state.fig is None and st.session_state.selected_files) # 선택은 ν–ˆλŠ”λ° 아직 κ·Έλž˜ν”„ 없을 λ•Œ
687
 
688
- if should_generate_graph and st.session_state.get('fasttext_model'): # λͺ¨λΈ λ‘œλ“œ 확인
689
- with st.spinner('의미 기반 κ·Έλž˜ν”„ 생성 쀑... μž μ‹œλ§Œ κΈ°λ‹€λ €μ£Όμ„Έμš”.'):
690
  try:
691
- # generate_graph ν•¨μˆ˜ 호좜 (λͺ¨λ“  νŒŒλΌλ―Έν„° 전달)
692
- fig = generate_graph(
693
- st.session_state.selected_files,
694
- st.session_state.threshold,
695
- st.session_state.perplexity,
696
- st.session_state.learning_rate,
697
- st.session_state.n_iter
698
- )
699
- st.session_state.fig = fig # 성곡 μ‹œ fig μ €μž₯
700
  except Exception as e:
701
- st.error(f"κ·Έλž˜ν”„ 생성 쀑 μ‹¬κ°ν•œ 였λ₯˜ λ°œμƒ: {e}")
702
- st.session_state.fig = None # μ‹€νŒ¨ μ‹œ fig μ΄ˆκΈ°ν™”
703
- finally:
704
- st.session_state.generate_clicked = False # μž‘μ—… μ™„λ£Œ ν›„ 클릭 ν”Œλž˜κ·Έ 리셋
705
 
706
- # μƒμ„±λœ κ·Έλž˜ν”„κ°€ 있으면 ν‘œμ‹œ
707
  if st.session_state.get('fig') is not None:
708
  st.plotly_chart(st.session_state.fig, use_container_width=True)
709
 
710
  # ν˜„μž¬ κ·Έλž˜ν”„ 정보 ν‘œμ‹œ
711
  try:
 
 
 
712
  num_nodes = len(st.session_state.fig.data[1].x) if len(st.session_state.fig.data) > 1 and hasattr(st.session_state.fig.data[1], 'x') else 0
713
  num_edges = len(st.session_state.fig.data[0].x) // 3 if len(st.session_state.fig.data) > 0 and hasattr(st.session_state.fig.data[0], 'x') and st.session_state.fig.data[0].x else 0
714
 
715
- # μ‚¬μš©λœ 파일 이름 μ–»κΈ° (데이터 λ‘œλ“œ ν›„)
716
- current_data_files = st.session_state.get('data_files', {})
717
- selected_file_names = [current_data_files[fid]['name'] for fid in st.session_state.selected_files if fid in current_data_files]
718
 
719
  st.info(f"""
720
  **ν˜„μž¬ κ·Έλž˜ν”„ 정보**
@@ -725,35 +829,73 @@ if st.session_state.selected_files:
725
  except Exception as info_e:
726
  st.warning(f"κ·Έλž˜ν”„ 정보 ν‘œμ‹œ 쀑 였λ₯˜: {info_e}")
727
 
 
728
  # μ‚¬μš© μ„€λͺ…
729
  with st.expander("πŸ’‘ κ·Έλž˜ν”„ μ‘°μž‘ 방법"):
730
  st.markdown("""
731
- - **ν™•λŒ€/μΆ•μ†Œ:** 마우슀 휠 슀크둀
732
  - **νšŒμ „:** 마우슀 μ™Όμͺ½ λ²„νŠΌ λˆ„λ₯Έ μƒνƒœλ‘œ λ“œλž˜κ·Έ
733
- - **이동 (Pan):** 마우슀 였λ₯Έμͺ½ λ²„νŠΌ λˆ„λ₯Έ μƒνƒœλ‘œ λ“œλž˜κ·Έ
734
- - **단어 정보 확인:** 마우슀 μ»€μ„œλ₯Ό 단어(마컀) μœ„μ— 올리면 단어 이름과 μ—°κ²° 수λ₯Ό λ³Ό 수 μžˆμŠ΅λ‹ˆλ‹€.
735
- - **νˆ΄λ°”:** κ·ΈοΏ½οΏ½ν”„ 우츑 상단 νˆ΄λ°” μ•„μ΄μ½˜μœΌλ‘œ λ‹€μ–‘ν•œ κΈ°λŠ₯(λ‹€μš΄λ‘œλ“œ, μ΄ˆκΈ°ν™” λ“±) μ‚¬μš© κ°€λŠ₯.
736
  """)
737
- # κ·Έλž˜ν”„ 생성을 ν•΄μ•Όν•˜λŠ”λ° 아직 μ•ˆ ν•œ 경우 or 생성 μ‹€νŒ¨ν•œ 경우
738
- elif not should_generate_graph and st.session_state.fig is None:
 
 
739
  st.info("πŸ‘ˆ μ‚¬μ΄λ“œλ°”μ—μ„œ 'πŸ“Š κ·Έλž˜ν”„ 생성/μ—…λ°μ΄νŠΈ' λ²„νŠΌμ„ ν΄λ¦­ν•˜μ—¬ μ‹œκ°ν™”λ₯Ό μ‹œμž‘ν•˜μ„Έμš”.")
740
 
741
- # μ„ νƒλœ 파일이 μ—†λŠ” 경우
742
  elif not st.session_state.data_files:
743
  st.warning("ν‘œμ‹œν•  데이터 파일이 μ—†μŠ΅λ‹ˆλ‹€. νŒŒμΌμ„ μ—…λ‘œλ“œν•˜κ±°λ‚˜ `data` 폴더에 μœ νš¨ν•œ JSON νŒŒμΌμ„ μΆ”κ°€ν•˜μ„Έμš”.")
744
- else: # 데이터 νŒŒμΌμ€ μžˆμœΌλ‚˜ μ„ νƒν•˜μ§€ μ•Šμ€ 경우
 
745
  st.info("πŸ‘ˆ μ‚¬μ΄λ“œλ°”μ—μ„œ 뢄석할 데이터 νŒŒμΌμ„ μ„ νƒν•΄μ£Όμ„Έμš”.")
746
 
747
-
748
  # --- ν•˜λ‹¨ 정보 μ„Ήμ…˜ ---
749
  st.divider()
 
750
  with st.expander("ℹ️ 이 μ‹œκ°ν™” 도ꡬ에 λŒ€ν•˜μ—¬"):
751
- st.markdown(f"""
752
  이 λ„κ΅¬λŠ” λ‹€μŒκ³Ό 같은 과정을 톡해 ν•œκ΅­μ–΄ 단어 λ„€νŠΈμ›Œν¬λ₯Ό μ‹œκ°ν™”ν•©λ‹ˆλ‹€:
753
 
754
  1. **데이터 λ‘œλ”©:** μ‚¬μš©μžκ°€ μ œκ³΅ν•œ JSON νŒŒμΌμ—μ„œ 'word' ν•„λ“œλ₯Ό κ°€μ§„ 단어 λͺ©λ‘μ„ μΆ”μΆœν•©λ‹ˆλ‹€.
755
- 2. **단어 μž„λ² λ”© (FastText):** 각 단어λ₯Ό **사전 ν•™μŠ΅λœ FastText λͺ¨λΈ**(`{os.path.basename(FASTTEXT_MODEL_PATH)}` μ‚¬μš© 쀑)을 μ‚¬μš©ν•˜μ—¬ κ³ μ°¨μ›μ˜ 의미 λ²‘ν„°λ‘œ λ³€ν™˜ν•©λ‹ˆλ‹€.
756
- 3. **μœ μ‚¬λ„ 계산:** 단어 벑터 κ°„μ˜ **코사인 μœ μ‚¬λ„**λ₯Ό κ³„μ‚°ν•©λ‹ˆλ‹€.
757
- 4. **차원 μΆ•μ†Œ (t-SNE):** 고차원 벑터λ₯Ό 3μ°¨μ›μœΌλ‘œ μΆ•μ†Œν•˜μ—¬ μ‹œκ°ν™”ν•©λ‹ˆλ‹€. t-SNE νŒŒλΌλ―Έν„°(Perplexity: {st.session_state.perplexity}, Learning Rate: {st.session_state.learning_rate}, Iterations: {st.session_state.n_iter})λ₯Ό μ‘°μ ˆν•˜μ—¬ κ΅°μ§‘ ν˜•νƒœλ₯Ό λ―Έμ„Έ μ‘°μ •ν•  수 μžˆμŠ΅λ‹ˆλ‹€.
758
- 5. **κ·Έλž˜ν”„ 생성 및 μ‹œκ°ν™”:** μœ μ‚¬λ„κ°€ μ„€μ •λœ μž„κ³„κ°’(ν˜„μž¬: {st.session_state.threshold:.2f}) 이상인 단어듀을 μ—°κ²°ν•˜μ—¬ 3D λ„€νŠΈμ›Œν¬ κ·Έλž˜ν”„λ₯Ό μƒμ„±ν•˜κ³  ν‘œμ‹œν•©λ‹ˆλ‹€.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
759
  """)
 
14
  import matplotlib.font_manager as fm
15
  from sklearn.manifold import TSNE
16
  import warnings
 
 
 
17
  warnings.filterwarnings('ignore')
18
 
19
+ # --- (이전 μ½”λ“œλŠ” 동일) ---
20
+
21
  # νŽ˜μ΄μ§€ μ„€μ •
22
  st.set_page_config(
23
+ page_title="ν•œκ΅­μ–΄ 단어 의미 λ„€νŠΈμ›Œν¬ μ‹œκ°ν™”",
24
+ page_icon="πŸ”€",
25
  layout="wide"
26
  )
27
 
 
30
  UPLOAD_FOLDER = 'uploads'
31
 
32
  # 폴더 생성
 
 
33
  if not os.path.exists(UPLOAD_FOLDER):
34
  os.makedirs(UPLOAD_FOLDER)
35
 
36
+ # μ„Έμ…˜ μƒνƒœ μ΄ˆκΈ°ν™”
37
+ if 'model' not in st.session_state:
38
+ st.session_state.model = None
 
 
 
 
 
 
 
 
 
39
  if 'embeddings_cache' not in st.session_state:
40
+ st.session_state.embeddings_cache = {}
41
  if 'graph_cache' not in st.session_state:
42
  st.session_state.graph_cache = {}
43
  if 'data_files' not in st.session_state:
44
  st.session_state.data_files = {}
45
  if 'selected_files' not in st.session_state:
46
+ st.session_state.selected_files = [] # 리슀트둜 μ΄ˆκΈ°ν™”
47
  if 'threshold' not in st.session_state:
48
+ st.session_state.threshold = 0.7
 
 
 
 
 
 
49
  if 'generate_clicked' not in st.session_state:
50
  st.session_state.generate_clicked = False
51
  if 'fig' not in st.session_state:
52
  st.session_state.fig = None
53
 
54
+ # --- (ν•¨μˆ˜ μ •μ˜ 뢀뢄은 동일: set_korean_font, load_words_from_json, ...) ---
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
55
 
56
  # --- ν•œκΈ€ 폰트 μ„€μ • ν•¨μˆ˜ ---
57
  def set_korean_font():
58
+ """
59
+ ν˜„μž¬ μš΄μ˜μ²΄μ œμ— λ§žλŠ” ν•œκΈ€ 폰트λ₯Ό matplotlib 및 Plotly용으둜 μ„€μ • μ‹œλ„ν•˜κ³ ,
60
+ Plotlyμ—μ„œ μ‚¬μš©ν•  폰트 이름을 λ°˜ν™˜ν•©λ‹ˆλ‹€.
61
+ """
62
  system_name = platform.system()
63
+ plotly_font_name = None # Plotlyμ—μ„œ μ‚¬μš©ν•  폰트 이름
64
+
65
+ # Matplotlib 폰트 μ„€μ •
66
+ if system_name == "Windows":
67
+ font_name = "Malgun Gothic"
68
+ plotly_font_name = "Malgun Gothic"
69
+ elif system_name == "Darwin": # MacOS
70
+ font_name = "AppleGothic"
71
+ plotly_font_name = "AppleGothic"
72
+ elif system_name == "Linux":
73
+ # Linuxμ—μ„œ μ„ ν˜Έν•˜λŠ” ν•œκΈ€ 폰트 경둜 λ˜λŠ” 이름 μ„€μ •
74
+ font_path = "/usr/share/fonts/truetype/nanum/NanumGothic.ttf"
75
+ plotly_font_name_linux = "NanumGothic" # PlotlyλŠ” 폰트 '이름'을 주둜 μ‚¬μš©
76
+
77
+ if os.path.exists(font_path):
78
+ prop = fm.FontProperties(fname=font_path)
79
+ fm.fontManager.addfont(font_path) # μ‹œμŠ€ν…œμ— 폰트 μΆ”κ°€ (ν•„μš”ν•  수 있음)
80
+ font_name = prop.get_name()
81
+ plotly_font_name = plotly_font_name_linux
82
+ else:
83
+ # μ‹œμŠ€ν…œμ—μ„œ 'Nanum' 포함 폰트 μ°ΎκΈ° μ‹œλ„
84
+ try:
 
 
 
 
 
 
85
  available_fonts = [f.name for f in fm.fontManager.ttflist]
86
  nanum_fonts = [name for name in available_fonts if 'Nanum' in name]
87
  if nanum_fonts:
88
  font_name = nanum_fonts[0]
89
+ # Plotlyμ—μ„œ μ‚¬μš©ν•  이름도 λΉ„μŠ·ν•˜κ²Œ μ„€μ • (μ •ν™•ν•œ 이름은 μ‹œμŠ€ν…œλ§ˆλ‹€ λ‹€λ₯Ό 수 있음)
90
+ plotly_font_name = font_name if 'Nanum' in font_name else plotly_font_name_linux
91
  else:
92
+ # λ‹€λ₯Έ OS 폰트 μ‹œλ„ (Linuxμ—μ„œ λ“œλ¬Όμ§€λ§Œ)
93
+ if "Malgun Gothic" in available_fonts:
94
+ font_name = "Malgun Gothic"
95
+ plotly_font_name = "Malgun Gothic"
96
+ elif "AppleGothic" in available_fonts:
97
+ font_name = "AppleGothic"
98
+ plotly_font_name = "AppleGothic"
99
+ else:
100
+ font_name = None
101
+
102
+ except Exception as e:
103
+ print(f"Linux font search error: {e}")
104
+ font_name = None
105
 
106
+ if not font_name:
107
+ font_name = None
108
+ plotly_font_name = None # Plotly도 κΈ°λ³Έκ°’ μ‚¬μš©
109
+
110
+ else: # 기타 OS
111
+ font_name = None
112
+ plotly_font_name = None
113
+
114
+ # Matplotlib 폰트 μ„€μ • 적용
115
+ if font_name:
116
+ try:
117
  plt.rc('font', family=font_name)
118
  plt.rc('axes', unicode_minus=False)
119
  print(f"Matplotlib font set to: {font_name}")
120
+ except Exception as e:
121
+ print(f"Failed to set Matplotlib font '{font_name}': {e}")
122
  plt.rcdefaults()
123
  plt.rc('axes', unicode_minus=False)
124
+ else:
125
+ print("No suitable Korean font found for Matplotlib. Using default.")
 
126
  plt.rcdefaults()
127
  plt.rc('axes', unicode_minus=False)
128
 
129
+ if not plotly_font_name:
130
+ plotly_font_name = 'sans-serif' # Plotly κΈ°λ³Έκ°’ μ§€μ •
131
  print(f"Plotly font name to use: {plotly_font_name}")
132
+
133
+ return plotly_font_name # Plotlyμ—μ„œ μ‚¬μš©ν•  폰트 이름 λ°˜ν™˜
134
+
135
 
136
  # --- 데이터 λ‘œλ“œ ν•¨μˆ˜ ---
137
  def load_words_from_json(filepath):
 
139
  try:
140
  with open(filepath, 'r', encoding='utf-8') as f:
141
  data = json.load(f)
142
+ # dataκ°€ 리슀트 ν˜•νƒœλΌκ³  κ°€μ •
143
  if isinstance(data, list):
144
+ words = [item.get('word', '') for item in data if isinstance(item, dict) and item.get('word')] # dict ν˜•νƒœμ΄κ³  'word' ν‚€κ°€ μžˆλŠ”μ§€ 확인
145
+ # 빈 λ¬Έμžμ—΄ 제거
146
+ words = [word for word in words if word]
147
  if not words:
148
  st.warning(f"κ²½κ³ : 파일 '{os.path.basename(filepath)}'μ—μ„œ 'word' ν‚€λ₯Ό κ°€μ§„ μœ νš¨ν•œ 데이터λ₯Ό 찾을 수 μ—†μŠ΅λ‹ˆλ‹€.")
149
  return None
 
161
  st.error(f"'{os.path.basename(filepath)}' 데이터 λ‘œλ”© 쀑 였λ₯˜ λ°œμƒ: {e}")
162
  return None
163
 
164
+
165
  def scan_data_files():
166
+ """데이터 ν΄λ”μ—μ„œ μ‚¬μš© κ°€λŠ₯ν•œ λͺ¨λ“  JSON νŒŒμΌμ„ μŠ€μΊ”ν•˜κ³  정보λ₯Ό λ°˜ν™˜ν•©λ‹ˆλ‹€."""
167
  data_files = {}
168
+ # κΈ°λ³Έ 데이터 폴더 μŠ€μΊ”
169
  try:
170
  for file_path in glob.glob(os.path.join(DATA_FOLDER, '*.json')):
171
+ file_id = f"default_{os.path.basename(file_path)}" # 고유 ID 생성 방식 λ³€κ²½
172
  file_name = os.path.basename(file_path)
173
  words = load_words_from_json(file_path)
174
+ if words: # wordsκ°€ None이 μ•„λ‹ˆκ³  λΉ„μ–΄μžˆμ§€ μ•Šμ€ 경우
175
+ data_files[file_id] = {
176
+ 'path': file_path,
177
+ 'name': file_name,
178
+ 'word_count': len(words),
179
+ 'type': 'default',
180
+ 'sample_words': words[:5] # μƒ˜ν”Œ 단어 수 μ‘°μ • κ°€λŠ₯
181
+ }
182
  except Exception as e:
183
  st.error(f"κΈ°λ³Έ 데이터 폴더 μŠ€μΊ” 쀑 였λ₯˜: {e}")
184
+
185
+ # μ—…λ‘œλ“œ 폴더 μŠ€μΊ”
186
  try:
187
  for file_path in glob.glob(os.path.join(UPLOAD_FOLDER, '*.json')):
188
+ file_id = f"uploaded_{os.path.basename(file_path)}" # 고유 ID 생성 방식 λ³€κ²½
189
  file_name = os.path.basename(file_path)
190
  words = load_words_from_json(file_path)
191
+ if words: # wordsκ°€ None이 μ•„λ‹ˆκ³  λΉ„μ–΄μžˆμ§€ μ•Šμ€ 경우
192
+ data_files[file_id] = {
193
+ 'path': file_path,
194
+ 'name': file_name,
195
+ 'word_count': len(words),
196
+ 'type': 'uploaded',
197
+ 'sample_words': words[:5] # μƒ˜ν”Œ 단어 수 μ‘°μ • κ°€λŠ₯
198
+ }
199
  except Exception as e:
200
  st.error(f"μ—…λ‘œλ“œ 폴더 μŠ€μΊ” 쀑 였λ₯˜: {e}")
201
+
202
  return data_files
203
 
204
+
205
+ def merge_word_lists(file_ids):
206
  """μ„ νƒλœ νŒŒμΌλ“€μ—μ„œ 단어λ₯Ό λ‘œλ“œν•˜κ³  쀑볡 μ œκ±°ν•˜μ—¬ λ³‘ν•©ν•©λ‹ˆλ‹€."""
207
+ all_words = []
208
  if not file_ids:
209
  return []
210
 
211
+ # data_files μƒνƒœκ°€ μ΅œμ‹ μΈμ§€ 확인 (μ—…λ‘œλ“œ/μ‚­μ œ ν›„ ν•„μš”ν•  수 있음)
212
+ current_data_files = st.session_state.get('data_files', {})
213
+
214
  for file_id in file_ids:
215
  if file_id in current_data_files:
216
  file_path = current_data_files[file_id]['path']
217
  words = load_words_from_json(file_path)
218
  if words:
219
+ all_words.extend(words)
220
  else:
221
+ st.warning(f"μ„ νƒλœ 파일 ID '{file_id}'λ₯Ό ν˜„μž¬ 파일 λͺ©λ‘μ—μ„œ 찾을 수 μ—†μŠ΅λ‹ˆλ‹€. λͺ©λ‘μ„ μƒˆλ‘œκ³ μΉ¨ν•©λ‹ˆλ‹€.")
222
+ # 파일 λͺ©λ‘μ„ λ‹€μ‹œ μŠ€μΊ”ν•˜κ³  μž¬μ‹œλ„ (선택적)
223
+ st.session_state.data_files = scan_data_files()
224
+ if file_id in st.session_state.data_files:
225
+ words = load_words_from_json(st.session_state.data_files[file_id]['path'])
226
+ if words: all_words.extend(words)
227
+ else:
228
+ st.error(f"파일 '{file_id}'λ₯Ό μ—¬μ „νžˆ 찾을 수 μ—†μŠ΅λ‹ˆλ‹€.")
229
 
 
 
 
 
230
 
231
+ # 쀑볡 제거 및 μ •λ ¬
232
+ unique_words = sorted(list(set(all_words)))
233
+ return unique_words
234
 
235
+
236
+ def encode_words(words, normalize=True):
237
+ """단어 λͺ©λ‘μ„ μž„λ² λ”©μœΌλ‘œ λ³€ν™˜ν•©λ‹ˆλ‹€. (κ°œμ„ λœ TF-IDF μŠ€νƒ€μΌ μž„λ² λ”©)"""
238
  if not words:
239
  return np.array([])
240
 
241
  embeddings = []
242
+ # 전체 단어에 λ‚˜νƒ€λ‚˜λŠ” λͺ¨λ“  고유 문자둜 μ–΄νœ˜ ꡬ성
243
+ unique_chars = set(char for word in words for char in word)
244
+ char_to_idx = {char: i for i, char in enumerate(sorted(list(unique_chars)))}
245
+ dim = len(char_to_idx)
246
 
247
+ if dim == 0: # 단어가 μ•„μ˜ˆ μ—†λŠ” 경우
248
+ return np.array([])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
249
 
250
+ for word in words:
251
+ embed = np.zeros(dim)
252
+ word_len = len(word)
253
+ if word_len == 0: # 빈 λ¬Έμžμ—΄ 처리
254
+ embeddings.append(embed)
255
+ continue
256
+
257
+ # TF (Term Frequency): 단어 λ‚΄ 문자 λΉˆλ„
258
+ tf = {}
259
+ for char in word:
260
+ if char in char_to_idx:
261
+ tf[char] = tf.get(char, 0) + 1
262
+
263
+ for char, count in tf.items():
264
+ if char in char_to_idx:
265
+ # TF 계산 (μ—¬κΈ°μ„œλŠ” λ‹¨μˆœ λΉˆλ„ μ‚¬μš©, ν•„μš”μ‹œ log μŠ€μΌ€μΌλ§ λ“± 적용 κ°€λŠ₯)
266
+ embed[char_to_idx[char]] = count / word_len # 단어 길이둜 μ •κ·œν™”
267
+
268
+ # L2 μ •κ·œν™” (Cosine Similarityλ₯Ό μœ„ν•΄ 유용)
269
+ if normalize:
270
+ norm = np.linalg.norm(embed)
271
+ if norm > 0:
272
+ embed = embed / norm
273
+
274
+ embeddings.append(embed)
275
 
276
+ return np.array(embeddings)
277
+
278
+
279
+ def generate_graph(file_ids, similarity_threshold=0.7):
280
+ """μ—¬λŸ¬ νŒŒμΌμ—μ„œ 단어λ₯Ό λ‘œλ“œν•˜κ³  κ·Έλž˜ν”„λ₯Ό μƒμ„±ν•©λ‹ˆλ‹€."""
281
  if not file_ids:
282
  st.error("κ·Έλž˜ν”„λ₯Ό 생성할 파일이 μ„ νƒλ˜μ§€ μ•Šμ•˜μŠ΅λ‹ˆλ‹€.")
283
  return None
 
 
 
284
 
285
+ # μΊμ‹œ ν‚€ 생성 (파일 ID λ¦¬μŠ€νŠΈμ™€ μž„κ³„κ°’ μ‘°ν•©, μˆœμ„œ 보μž₯)
286
+ cache_key = f"{'-'.join(sorted(file_ids))}_{similarity_threshold}"
287
+ if cache_key in st.session_state.graph_cache:
288
+ # μΊμ‹œλœ κ²°κ³Ό λ°˜ν™˜
289
+ return st.session_state.graph_cache[cache_key]
290
+
291
+ # ν•œκΈ€ 폰트 μ„€μ •
292
+ plotly_font = set_korean_font()
293
+
294
+ # μ„ νƒλœ νŒŒμΌλ“€μ—μ„œ 단어 λ‘œλ“œ 및 병합
295
+ word_list = merge_word_lists(file_ids)
296
 
297
  if not word_list:
298
  st.error("μ„ νƒλœ νŒŒμΌμ—μ„œ μœ νš¨ν•œ 단어λ₯Ό λ‘œλ“œν•  수 μ—†μŠ΅λ‹ˆλ‹€.")
299
  return None
300
+
301
  if len(word_list) < 2:
302
  st.warning("κ·Έλž˜ν”„λ₯Ό μƒμ„±ν•˜λ €λ©΄ μ΅œμ†Œ 2개 μ΄μƒμ˜ 고유 단어가 ν•„μš”ν•©λ‹ˆλ‹€.")
303
  return None
304
 
305
+
306
+ # μž„λ² λ”© 생성
307
+ embeddings = None
308
+ with st.spinner('단어 μž„λ² λ”© 생성 쀑...'):
309
+ # μΊμ‹œ 확인 (파일 ID 기반)
310
+ embedding_cache_key = '-'.join(sorted(file_ids))
311
+ if embedding_cache_key in st.session_state.embeddings_cache:
312
+ word_list_cached, embeddings = st.session_state.embeddings_cache[embedding_cache_key]
313
+ # μΊμ‹œλœ 단어 λͺ©λ‘κ³Ό ν˜„μž¬ 단어 λͺ©λ‘μ΄ λ‹€λ₯΄λ©΄ μž¬μƒμ„±
314
+ if sorted(word_list_cached) != sorted(word_list):
315
+ embeddings = encode_words(word_list, normalize=True)
316
+ st.session_state.embeddings_cache[embedding_cache_key] = (word_list, embeddings)
317
+ else:
318
+ embeddings = encode_words(word_list, normalize=True)
319
+ st.session_state.embeddings_cache[embedding_cache_key] = (word_list, embeddings)
320
+
321
  if embeddings is None or embeddings.shape[0] == 0 or embeddings.shape[1] == 0:
322
+ st.error("단어 μž„λ² λ”© 생성에 μ‹€νŒ¨ν–ˆμŠ΅λ‹ˆλ‹€.")
323
  return None
324
 
325
+ # 3D μ’Œν‘œ 생성 - t-SNE μ‚¬μš©
326
  embeddings_3d = None
327
+ with st.spinner('단어 μ’Œν‘œ 계산 쀑 (t-SNE)...'):
328
+ # t-SNE νŒŒλΌλ―Έν„° μ„€μ • (데이터 크기에 따라 동적 쑰절)
329
+ n_samples = embeddings.shape[0]
330
+ # perplexityλŠ” n_samples - 1 보닀 μž‘μ•„μ•Ό 함
331
+ effective_perplexity = min(30, max(5, n_samples - 1)) # μ΅œμ†Œ 5, μ΅œλŒ€ 30 λ˜λŠ” μƒ˜ν”Œμˆ˜-1
332
+ # 반볡 횟수
333
+ max_iter = max(250, min(1000, n_samples * 5)) # μƒ˜ν”Œ μˆ˜μ— 따라 μ‘°μ ˆν•˜λ˜ μ΅œμ†Œ/μ΅œλŒ€κ°’ μ„€μ •
334
+ # ν•™μŠ΅λ₯ 
335
+ learning_rate = max(10, min(200, n_samples / 12)) if n_samples > 12 else 'auto' # μƒ˜ν”Œ 수 기반, λ„ˆλ¬΄ μž‘μœΌλ©΄ auto
336
+
337
+ if n_samples <= 3: # t-SNEλŠ” μ΅œμ†Œ 4개 μƒ˜ν”Œ ꢌμž₯
338
+ st.warning(f"t-SNEλŠ” μ΅œμ†Œ 4개의 단어가 ν•„μš”ν•©λ‹ˆλ‹€ (ν˜„μž¬ {n_samples}개). PCAλ₯Ό μ‚¬μš©ν•©λ‹ˆλ‹€.")
339
+ from sklearn.decomposition import PCA
340
+ pca = PCA(n_components=min(3, n_samples), random_state=42) # μ΅œλŒ€ 3차원 λ˜λŠ” μƒ˜ν”Œ 수
341
+ embeddings_3d_pca = pca.fit_transform(embeddings)
342
+ # 3μ°¨μ›μœΌλ‘œ λ§žμΆ”κΈ° (λΆ€μ‘±ν•˜λ©΄ 0으둜 채움)
343
+ embeddings_3d = np.zeros((n_samples, 3))
344
+ embeddings_3d[:, :embeddings_3d_pca.shape[1]] = embeddings_3d_pca
345
  else:
346
+ try:
347
+ # max_iter λ³€μˆ˜ 동적 계산 및 ν• λ‹Ή
348
+ max_iter = max(250, min(1000, n_samples * 5)) # <--- 이 쀄을 μ‹€μ œ μ½”λ“œλ‘œ μΆ”κ°€/ν™œμ„±ν™”
349
+
350
+ tsne = TSNE(n_components=3, random_state=42,
351
+ perplexity=effective_perplexity,
352
+ n_iter=max_iter, # 이제 μ •μ˜λœ max_iter μ‚¬μš©
353
+ init='pca',
354
+ learning_rate=learning_rate,
355
+ n_jobs=-1)
356
+ embeddings_3d = tsne.fit_transform(embeddings)
357
+ except Exception as e:
358
+ st.error(f"t-SNE μ‹€ν–‰ 쀑 였λ₯˜ λ°œμƒ: {e}. PCA둜 λŒ€μ²΄ν•©λ‹ˆλ‹€.")
359
+ from sklearn.decomposition import PCA
360
+ pca = PCA(n_components=3, random_state=42)
361
+ embeddings_3d = pca.fit_transform(embeddings)
362
+
363
+ if embeddings_3d is None:
364
+ st.error("단어 μ’Œν‘œ 생성에 μ‹€νŒ¨ν–ˆμŠ΅λ‹ˆλ‹€.")
365
  return None
366
 
367
+ # μœ μ‚¬λ„ 계산 및 μ—£μ§€ μ •μ˜
368
  edges = []
369
  edge_weights = []
370
+ with st.spinner('단어 κ°„ μœ μ‚¬λ„ 계산 및 μ—°κ²°(μ—£μ§€) 생성 쀑...'):
371
+ # μœ μ‚¬λ„ ν–‰λ ¬ 계산
372
+ similarity_matrix = cosine_similarity(embeddings)
373
+
374
+ # μž„κ³„κ°’ 이상인 μ—£μ§€λ§Œ μΆ”κ°€
375
+ for i in range(n_samples):
376
+ for j in range(i + 1, n_samples): # 쀑볡 및 자기 μžμ‹  μ—°κ²° λ°©μ§€
377
+ similarity = similarity_matrix[i, j]
378
+ if similarity >= similarity_threshold: # λ“±ν˜Έ 포함 (μž„κ³„κ°’κ³Ό 같아도 μ—°κ²°)
379
+ edges.append((word_list[i], word_list[j]))
380
+ edge_weights.append(similarity)
381
+
382
+ # NetworkX κ·Έλž˜ν”„ 생성
 
383
  G = nx.Graph()
384
+ # λ…Έλ“œ μΆ”κ°€ (단어와 3D μ’Œν‘œ)
385
  for i, word in enumerate(word_list):
386
+ G.add_node(word, pos=(embeddings_3d[i, 0], embeddings_3d[i, 1], embeddings_3d[i, 2]))
 
 
 
 
387
 
388
+ # 엣지와 κ°€μ€‘μΉ˜ μΆ”κ°€
 
 
 
389
  for edge, weight in zip(edges, edge_weights):
390
+ # self-loop λ°©μ§€ (이둠상 μœ„ λ‘œμ§μ—μ„œ λ°œμƒ μ•ˆ 함)
391
+ if edge[0] != edge[1]:
392
  G.add_edge(edge[0], edge[1], weight=weight)
 
393
 
394
+ # Plotly κ·Έλž˜ν”„ 생성
395
  edge_x, edge_y, edge_z = [], [], []
396
  if G.number_of_edges() > 0:
397
  for edge in G.edges():
398
  try:
399
  pos0 = G.nodes[edge[0]]['pos']
400
  pos1 = G.nodes[edge[1]]['pos']
401
+ edge_x.extend([pos0[0], pos1[0], None]) # None은 μ„  끊기
402
  edge_y.extend([pos0[1], pos1[1], None])
403
  edge_z.extend([pos0[2], pos1[2], None])
404
  except KeyError as e:
405
+ st.warning(f"μ—£μ§€ 생성 쀑 λ…Έλ“œ ν‚€ 였λ₯˜: {e}. ν•΄λ‹Ή μ—£μ§€λ₯Ό κ±΄λ„ˆ<0xEB><0x84>λ‹ˆλ‹€.")
406
+ continue # λ¬Έμ œκ°€ μžˆλŠ” μ—£μ§€λŠ” κ±΄λ„ˆλœ€
407
+
408
+ # μ—£μ§€ 트레이슀
409
+ edge_trace = go.Scatter3d(
410
+ x=edge_x, y=edge_y, z=edge_z,
411
+ mode='lines',
412
+ line=dict(width=1, color='#888'),
413
+ hoverinfo='none' # μ—£μ§€μ—λŠ” ν˜Έλ²„ 정보 μ—†μŒ
414
+ )
415
 
416
+ # λ…Έλ“œ μ’Œν‘œ 및 ν…μŠ€νŠΈ 정보
417
+ node_x, node_y, node_z, node_text = [], [], [], []
418
+ node_adjacencies = [] # μ—°κ²° 수 (degree)
419
+ node_hover_text = [] # ν˜Έλ²„ ν…μŠ€νŠΈ
 
 
420
 
421
+ nodes_data = []
422
+ for node in G.nodes():
423
+ try:
424
+ pos = G.nodes[node]['pos']
425
+ degree = G.degree(node) # λ…Έλ“œμ˜ μ—°κ²° 수 계산
426
+ nodes_data.append({
427
+ 'x': pos[0], 'y': pos[1], 'z': pos[2],
428
+ 'text': node,
429
+ 'degree': degree,
430
+ 'hover_text': f'{node}<br>μ—°κ²° 수: {degree}'
431
+ })
432
+ except KeyError:
433
+ st.warning(f"λ…Έλ“œ '{node}' 처리 쀑 'pos' ν‚€ 였λ₯˜. ν•΄λ‹Ή λ…Έλ“œλ₯Ό κ±΄λ„ˆ<0xEB><0x84>λ‹ˆλ‹€.")
434
+ continue # μœ„μΉ˜ 정보 μ—†λŠ” λ…Έλ“œ κ±΄λ„ˆλœ€
435
+
436
+ # λ…Έλ“œ 데이터가 μžˆμ„ κ²½μš°μ—λ§Œ 처리
437
+ if nodes_data:
438
+ # λ…Έλ“œ 크기λ₯Ό μ—°κ²° μˆ˜μ— 따라 쑰절 (μ˜ˆμ‹œ: 둜그 μŠ€μΌ€μΌλ§)
439
+ degrees = np.array([data['degree'] for data in nodes_data])
440
+ # 둜그 μŠ€μΌ€μΌλ§ 적용 (0인 경우 λŒ€λΉ„ +1), μ΅œλŒ€/μ΅œμ†Œ 크기 μ œν•œ
441
+ node_sizes = np.log1p(degrees) * 3 + 6 # κΈ°λ³Έ 크기 6, μ—°κ²° λ§Žμ„μˆ˜λ‘ 컀짐
442
+ node_sizes = np.clip(node_sizes, 5, 20) # μ΅œμ†Œ 5, μ΅œλŒ€ 20
443
+
444
+ # λ…Έλ“œ 데이터 뢄리
445
+ node_x = [data['x'] for data in nodes_data]
446
+ node_y = [data['y'] for data in nodes_data]
447
+ node_z = [data['z'] for data in nodes_data]
448
+ node_text = [data['text'] for data in nodes_data]
449
+ node_hover_text = [data['hover_text'] for data in nodes_data]
450
+
451
+ # λ…Έλ“œ 트레이슀
452
+ node_trace = go.Scatter3d(
453
+ x=node_x, y=node_y, z=node_z,
454
+ mode='markers+text', # λ§ˆμ»€μ™€ ν…μŠ€νŠΈ ν•¨κ»˜ ν‘œμ‹œ
455
+ text=node_text, # λ…Έλ“œ μœ„μ— ν‘œμ‹œλ  ν…μŠ€νŠΈ
456
+ hovertext=node_hover_text, # 마우슀 μ˜¬λ Έμ„ λ•Œ ν‘œμ‹œλ  ν…μŠ€νŠΈ
457
+ hoverinfo='text', # ν˜Έλ²„ μ‹œ hovertext만 ν‘œμ‹œ
458
+ textposition='top center', # ν…μŠ€νŠΈ μœ„μΉ˜
459
+ textfont=dict(
460
+ size=10,
461
+ color='black',
462
+ family=plotly_font # μ„€μ •λœ ν•œκΈ€ 폰트 μ‚¬μš©
463
+ ),
464
+ marker=dict(
465
+ size=node_sizes, # μ—°κ²° μˆ˜μ— 따라 크기 쑰절된 리슀트
466
+ color=node_z, # ZμΆ• κ°’μœΌλ‘œ 색상 λ§€ν•‘
467
+ colorscale='Viridis', # 색상 μŠ€μΌ€μΌ
468
+ opacity=0.9,
469
+ colorbar=dict(thickness=15, title='Node Depth (Z)', xanchor='left', titleside='right')
470
+ )
471
  )
472
+ else:
473
+ # λ…Έλ“œ 데이터가 μ—†μœΌλ©΄ 빈 트레이슀 생성
474
+ node_trace = go.Scatter3d(x=[], y=[], z=[], mode='markers')
475
 
476
+
477
+ # μ‚¬μš©λœ 파일 이름 λͺ©λ‘ 생성
478
+ file_names_used = []
479
+ if 'data_files' in st.session_state:
480
+ file_names_used = [st.session_state.data_files[fid]['name'] for fid in file_ids if fid in st.session_state.data_files]
481
  file_info_str = ", ".join(file_names_used) if file_names_used else "μ•Œ 수 μ—†μŒ"
482
 
483
+
484
+ # λ ˆμ΄μ•„μ›ƒ μ„€μ •
485
  layout = go.Layout(
486
  title=dict(
487
+ text=f'<b>μ–΄νœ˜ 의미 μœ μ‚¬μ„± 기반 3D κ·Έλž˜ν”„</b><br>Threshold: {similarity_threshold:.2f} | 데이터: {file_info_str}',
488
  font=dict(size=16, family=plotly_font),
489
+ x=0.5, # 제λͺ© 쀑앙 μ •λ ¬
490
+ xanchor='center'
491
  ),
492
+ showlegend=False, # λ²”λ‘€ μˆ¨κΉ€
493
+ margin=dict(l=10, r=10, b=10, t=80), # μ—¬λ°± 쑰절 (제λͺ© 곡간 확보)
494
  scene=dict(
495
+ xaxis=dict(
496
+ title='TSNE-1', showticklabels=False, # μΆ• 눈금 μˆ¨κΉ€
497
+ backgroundcolor="rgb(240, 240, 240)", gridcolor="white", zerolinecolor="white"
498
+ ),
499
+ yaxis=dict(
500
+ title='TSNE-2', showticklabels=False,
501
+ backgroundcolor="rgb(240, 240, 240)", gridcolor="white", zerolinecolor="white"
502
+ ),
503
+ zaxis=dict(
504
+ title='TSNE-3', showticklabels=False,
505
+ backgroundcolor="rgb(240, 240, 240)", gridcolor="white", zerolinecolor="white"
506
+ ),
507
+ aspectratio=dict(x=1, y=1, z=0.8), # κ°€λ‘œμ„Έλ‘œλΉ„ 쑰절
508
+ camera=dict(
509
+ eye=dict(x=1.2, y=1.2, z=0.8) # 초기 카메라 μ‹œμ 
510
+ )
511
  ),
512
+ # ν˜Έλ²„ λͺ¨λ“œ μ„€μ • (κ°€μž₯ κ°€κΉŒμš΄ 데이터 포인트 λ˜λŠ” 톡합)
513
  hovermode='closest'
514
  )
515
 
516
+ # Figure 객체 생성
517
  fig = go.Figure(data=[edge_trace, node_trace], layout=layout)
518
 
519
+ # κ²°κ³Ό μΊμ‹œ μ €μž₯
520
  st.session_state.graph_cache[cache_key] = fig
521
 
522
  return fig
523
 
524
+
525
  def handle_uploaded_file(uploaded_file):
526
+ """μ—…λ‘œλ“œλœ νŒŒμΌμ„ μ²˜λ¦¬ν•˜κ³  데이터 파일 λͺ©λ‘μ— μΆ”κ°€ν•©λ‹ˆλ‹€."""
527
  if uploaded_file is not None:
528
+ # 파일λͺ… μ•ˆμ „ 처리 (uuid μ‚¬μš© ꢌμž₯) 및 μ €μž₯ 경둜
529
+ # original_name = uploaded_file.name
530
+ unique_id = str(uuid.uuid4()) # 고유 ID 생성
531
+ # file_extension = os.path.splitext(original_name)[1]
532
+ # file_name = f"{unique_id}{file_extension}" # 고유 ID둜 파일λͺ… 생성
533
+ file_name = f"{unique_id}_{uploaded_file.name}" # 원본 이름 일뢀 포함 (선택적)
534
  file_path = os.path.join(UPLOAD_FOLDER, file_name)
535
 
536
  try:
537
+ # 파일 μ €μž₯
538
  with open(file_path, 'wb') as f:
539
  f.write(uploaded_file.getbuffer())
540
+ st.info(f"파일 '{uploaded_file.name}' ({file_name}) μ €μž₯ μ™„λ£Œ. λ‚΄μš© 검증 쀑...")
541
 
542
+ # μ—…λ‘œλ“œλœ 파일 검증 (단어 λ‘œλ“œ μ‹œλ„)
543
  words = load_words_from_json(file_path)
544
+
545
+ if words is None or not words : # λ‘œλ“œ μ‹€νŒ¨ λ˜λŠ” 빈 리슀트
546
+ try:
547
+ os.remove(file_path) # μœ νš¨ν•˜μ§€ μ•ŠμœΌλ©΄ 파일 μ‚­μ œ
548
+ st.error(f"μ—…λ‘œλ“œλœ 파일 '{uploaded_file.name}'μ—μ„œ μœ νš¨ν•œ 'word' 데이터λ₯Ό 찾을 수 μ—†μŠ΅λ‹ˆλ‹€. 파일 ν˜•μ‹(UTF-8 인코딩 JSON λ°°μ—΄, 각 객체에 'word' ν‚€)을 ν™•μΈν•΄μ£Όμ„Έμš”. 파일이 μ‚­μ œλ˜μ—ˆμŠ΅λ‹ˆλ‹€.")
549
+ except OSError as e:
550
+ st.error(f"μœ νš¨ν•˜μ§€ μ•Šμ€ νŒŒμΌμ„ μ‚­μ œν•˜λŠ” 쀑 였λ₯˜ λ°œμƒ: {e}")
551
+ return None # μ‹€νŒ¨ μ‹œ None λ°˜ν™˜
552
+
553
+ st.success(f"파일 '{uploaded_file.name}' 검증 μ™„λ£Œ. {len(words)}개의 단어λ₯Ό μ°Ύμ•˜μŠ΅λ‹ˆλ‹€.")
554
+
555
+ # 데이터 파일 λ‹€μ‹œ μŠ€μΊ”ν•˜μ—¬ μƒˆ 파일 정보 포함 (μ„Έμ…˜ μƒνƒœ μ—…λ°μ΄νŠΈ)
556
+ st.session_state.data_files = scan_data_files()
557
+
558
+ # μƒˆ νŒŒμΌμ— ν•΄λ‹Ήν•˜λŠ” file_id μ°ΎκΈ° (scan_data_filesμ—μ„œ μƒμ„±λœ ID μ‚¬μš©)
559
+ new_file_id = f"uploaded_{file_name}" # scan_data_files와 λ™μΌν•œ 둜직으둜 ID 생성
560
+ if new_file_id in st.session_state.data_files:
561
+ return new_file_id # 성곡 μ‹œ 파일 ID λ°˜ν™˜
562
  else:
563
+ st.error("파일 λͺ©λ‘ μ—…λ°μ΄νŠΈ 후에도 μƒˆ 파일 IDλ₯Ό μ°Ύμ§€ λͺ»ν–ˆμŠ΅λ‹ˆλ‹€.")
564
+ return None
565
+
 
 
566
  except Exception as e:
567
+ st.error(f"파일 μ—…λ‘œλ“œ 및 처리 쀑 였λ₯˜ λ°œμƒ: {e}")
568
+ # 였λ₯˜ λ°œμƒ μ‹œ μ—…λ‘œλ“œλœ 파일 μ‚­μ œ μ‹œλ„
569
+ try:
570
+ if os.path.exists(file_path):
571
+ os.remove(file_path)
572
+ except OSError as del_e:
573
+ st.warning(f"였λ₯˜ λ°œμƒ ν›„ 파일 μ‚­μ œ μ‹€νŒ¨: {del_e}")
574
+ return None # μ‹€νŒ¨ μ‹œ None λ°˜ν™˜
575
+
576
 
577
  def delete_file(file_id):
578
+ """νŒŒμΌμ„ μ‚­μ œν•©λ‹ˆλ‹€."""
579
+ if file_id not in st.session_state.get('data_files', {}):
 
580
  st.error('μ‚­μ œν•  νŒŒμΌμ„ 찾을 수 μ—†μŠ΅λ‹ˆλ‹€.')
581
  return False
582
 
583
+ file_info = st.session_state.data_files[file_id]
584
+
585
+ # μ—…λ‘œλ“œλœ 파일만 μ‚­μ œ ν—ˆμš©
586
  if file_info.get('type') != 'uploaded':
587
  st.error('κΈ°λ³Έ 데이터 νŒŒμΌμ€ μ‚­μ œν•  수 μ—†μŠ΅λ‹ˆλ‹€.')
588
  return False
 
590
  file_path = file_info.get('path')
591
  file_name = file_info.get('name', 'μ•Œ 수 μ—†μŒ')
592
 
593
+ if not file_path:
594
+ st.error(f"파일 '{file_name}'의 경둜 정보가 μ—†μŠ΅λ‹ˆλ‹€.")
595
+ return False
596
+
597
  try:
598
+ # 파일 μ‹œμŠ€ν…œμ—μ„œ 파일 μ‚­μ œ
599
+ if os.path.exists(file_path):
600
  os.remove(file_path)
601
+ st.info(f"파일 μ‹œμŠ€ν…œμ—μ„œ '{file_name}' μ‚­μ œ μ™„λ£Œ.")
602
  else:
603
+ st.warning(f"파일 μ‹œμŠ€ν…œμ— '{file_name}'({file_path})이(κ°€) 이미 μ‘΄μž¬ν•˜μ§€ μ•ŠμŠ΅λ‹ˆλ‹€.")
604
 
605
+ # μ„Έμ…˜ μƒνƒœμ—μ„œ 파일 정보 제거
606
  del st.session_state.data_files[file_id]
 
 
607
 
608
+ # κ΄€λ ¨ μΊμ‹œ ν•­λͺ© μ‚­μ œ (κ·Έλž˜ν”„, μž„λ² λ”©)
609
+ keys_to_remove_graph = [k for k in st.session_state.graph_cache if file_id in k]
610
+ for key in keys_to_remove_graph:
611
  del st.session_state.graph_cache[key]
 
612
 
613
+ keys_to_remove_embed = [k for k in st.session_state.embeddings_cache if file_id in k]
614
+ for key in keys_to_remove_embed:
615
+ del st.session_state.embeddings_cache[key]
616
+
617
+ # ν˜„μž¬ μ„ νƒλœ 파일 λͺ©λ‘μ—μ„œλ„ 제거
618
+ if file_id in st.session_state.selected_files:
619
+ st.session_state.selected_files.remove(file_id)
620
+
621
+ st.success(f"파일 '{file_name}' κ΄€λ ¨ 정보 및 μΊμ‹œκ°€ μ‚­μ œλ˜μ—ˆμŠ΅λ‹ˆλ‹€.")
622
  return True
623
 
624
  except Exception as e:
625
  st.error(f"파일 μ‚­μ œ 쀑 였λ₯˜ λ°œμƒ: {e}")
626
  return False
627
 
628
+
629
  def clear_cache():
630
+ """κ·Έλž˜ν”„ 및 μž„λ² λ”© μΊμ‹œλ₯Ό μ΄ˆκΈ°ν™”ν•©λ‹ˆλ‹€."""
631
  st.session_state.graph_cache = {}
632
+ st.session_state.embeddings_cache = {}
633
+ st.session_state.fig = None # ν˜„μž¬ ν‘œμ‹œμ€‘μΈ κ·Έλž˜ν”„λ„ μ΄ˆκΈ°ν™”
634
+ st.success('κ·Έλž˜ν”„ 및 μž„λ² λ”© μΊμ‹œκ°€ μ΄ˆκΈ°ν™”λ˜μ—ˆμŠ΅λ‹ˆλ‹€.')
635
+ st.experimental_rerun() # μΊμ‹œ 클리어 ν›„ UI κ°±μ‹ 
 
636
 
 
 
 
637
 
638
+ # --- μ•± μ‹€ν–‰ μ‹œμž‘ ---
 
 
 
639
 
640
+ # 데이터 파일 μŠ€μΊ” (μ•± μ‹œμž‘ μ‹œ λ˜λŠ” ν•„μš” μ‹œ)
641
  if 'data_files' not in st.session_state or not st.session_state.data_files:
642
  st.session_state.data_files = scan_data_files()
643
 
644
  # 타이틀 및 μ†Œκ°œ
645
+ st.title('ν•œκ΅­μ–΄ 단어 의미 λ„€νŠΈμ›Œν¬ μ‹œκ°ν™”')
646
  st.markdown("""
647
+ 이 λ„κ΅¬λŠ” 제곡된 JSON νŒŒμΌμ—μ„œ ν•œκ΅­μ–΄ 단어 λͺ©λ‘μ„ 읽어듀여, 단어 κ°„μ˜ 의미적 μœ μ‚¬μ„±(μ—¬κΈ°μ„œλŠ” 문자 ꡬ성 기반 μœ μ‚¬μ„±)을 κ³„μ‚°ν•˜κ³ ,
648
+ κ·Έ 관계λ₯Ό μΈν„°λž™ν‹°λΈŒν•œ 3D λ„€νŠΈμ›Œν¬ κ·Έλž˜ν”„λ‘œ μ‹œκ°ν™”ν•©λ‹ˆλ‹€.
649
  """)
650
 
651
+ # --- μ‚¬μ΄λ“œλ°” μ„€μ • ---
 
 
 
 
 
 
652
  st.sidebar.title('βš™οΈ μ„€μ • 및 μ œμ–΄')
653
 
654
+ # μž„κ³„κ°’ μ„€μ •
655
  threshold = st.sidebar.slider(
656
+ 'μœ μ‚¬λ„ μž„κ³„κ°’ (Similarity Threshold)',
657
+ min_value=0.1,
658
+ max_value=0.95, # μ΅œλŒ€κ°’ μ•½κ°„ 늘림
659
+ value=st.session_state.threshold,
660
+ step=0.05,
661
+ help='이 값보닀 μœ μ‚¬λ„κ°€ 높은 λ‹¨μ–΄λ“€λ§Œ μ—°κ²°μ„ (μ—£μ§€)으둜 μ΄μ–΄μ§‘λ‹ˆλ‹€. 값이 λ†’μ„μˆ˜λ‘ 연결이 더 μ—„κ²©ν•΄μ§‘λ‹ˆλ‹€.'
662
  )
663
+ # μŠ¬λΌμ΄λ” 값이 λ³€κ²½λ˜λ©΄ μ„Έμ…˜ μƒνƒœ μ—…λ°μ΄νŠΈ (콜백 μ‚¬μš©μ΄ 더 효율적일 수 있음)
664
  if threshold != st.session_state.threshold:
665
  st.session_state.threshold = threshold
666
+ st.session_state.fig = None # μž„κ³„κ°’ λ³€κ²½ μ‹œ ν˜„μž¬ κ·Έλž˜ν”„ μ΄ˆκΈ°ν™” (μž¬μƒμ„± ν•„μš” μ•Œλ¦Ό)
667
+ st.session_state.generate_clicked = False # 클릭 μƒνƒœλ„ 리셋
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
668
 
669
  st.sidebar.divider()
670
 
671
+ # 파일 μ—…λ‘œλ“œ
672
  st.sidebar.header('πŸ“„ 파일 μ—…λ‘œλ“œ')
673
  uploaded_file = st.sidebar.file_uploader(
674
+ "JSON 파일 μ—…λ‘œλ“œ",
675
+ type=['json'],
676
+ help="단어 λͺ©λ‘μ΄ ν¬ν•¨λœ JSON νŒŒμΌμ„ μ—…λ‘œλ“œν•˜μ„Έμš”. ν˜•μ‹: [{'word': '단어1'}, {'word': '단어2'}, ...]"
677
  )
678
+
679
  if uploaded_file is not None:
680
+ # μ—…λ‘œλ“œ λ²„νŠΌ λŒ€μ‹  파일이 있으면 λ°”λ‘œ 처리 μ‹œλ„ (μ‚¬μš©μž κ²½ν—˜ κ°œμ„ )
681
+ # if st.sidebar.button('μ—…λ‘œλ“œ 처리', key='upload_button'): # λ²„νŠΌ 제거
682
  with st.spinner("μ—…λ‘œλ“œλœ 파일 처리 쀑..."):
683
  new_file_id = handle_uploaded_file(uploaded_file)
684
  if new_file_id:
685
+ st.sidebar.success(f"파일 '{uploaded_file.name}' μ—…λ‘œλ“œ 및 처리 μ™„λ£Œ!")
686
+ # μƒˆλ‘œ μ—…λ‘œλ“œλœ νŒŒμΌμ„ μžλ™μœΌλ‘œ 선택 λͺ©λ‘μ— μΆ”κ°€ν•˜κ³  선택 μƒνƒœλ‘œ λ§Œλ“¦
687
  if new_file_id not in st.session_state.selected_files:
688
  st.session_state.selected_files.append(new_file_id)
689
+ # 슀크립트 μž¬μ‹€ν–‰ν•˜μ—¬ UI μ—…λ°μ΄νŠΈ
690
+ st.experimental_rerun()
691
+ else:
692
+ # handle_uploaded_file λ‚΄λΆ€μ—μ„œ 였λ₯˜ λ©”μ‹œμ§€ ν‘œμ‹œλ¨
693
+ pass
694
+ # μ—…λ‘œλ“œ μœ„μ ― μ΄ˆκΈ°ν™”λ₯Ό μœ„ν•΄ None ν• λ‹Ή (선택적)
695
+ # uploaded_file = None # μ΄λ ‡κ²Œ ν•˜λ©΄ 파일 선택 창이 λ‹€μ‹œ λ‚˜νƒ€λ‚¨, ν•„μš”μ— 따라 쑰절
696
 
697
  st.sidebar.divider()
698
 
699
+ # 파일 선택 μ˜μ—­
700
  st.sidebar.header('πŸ—‚οΈ 데이터 파일 선택')
701
+
702
+ if st.session_state.data_files:
703
+ # μ‚¬μš©ν•  파일 선택 μ²΄ν¬λ°•μŠ€
704
+ st.sidebar.markdown("**μ‚¬μš©ν•  νŒŒμΌμ„ μ„ νƒν•˜μ„Έμš” (닀쀑 선택 κ°€λŠ₯):**")
705
+
706
+ # 선택 μƒνƒœ 관리λ₯Ό μœ„ν•œ μž„μ‹œ 리슀트
707
  selected_files_temp = []
708
+ # 파일 λͺ©λ‘ μ •λ ¬ (μ΄λ¦„μˆœ)
709
+ sorted_file_ids = sorted(st.session_state.data_files.keys(), key=lambda fid: st.session_state.data_files[fid]['name'])
710
+
711
 
712
+ # 각 νŒŒμΌμ— λŒ€ν•œ μ²΄ν¬λ°•μŠ€ 및 정보 ν‘œμ‹œ
713
  for file_id in sorted_file_ids:
714
+ if file_id not in st.session_state.data_files: continue # μ‚­μ œλœ 경우 κ±΄λ„ˆλ›°κΈ°
715
+ file_info = st.session_state.data_files[file_id]
716
+
717
  file_label = f"{file_info['name']} ({file_info['word_count']} 단어)"
718
  file_type_tag = "[κΈ°λ³Έ]" if file_info['type'] == 'default' else "[μ—…λ‘œλ“œ]"
719
  label_full = f"{file_label} {file_type_tag}"
720
+
721
+ # ν˜„μž¬ 파일이 μ„ νƒλ˜μ—ˆλŠ”μ§€ 확인 (μ„Έμ…˜ μƒνƒœ κΈ°μ€€)
722
  is_selected = file_id in st.session_state.selected_files
723
 
724
+ # μ²΄ν¬λ°•μŠ€ 생성
725
+ checkbox_key = f"cb_{file_id}" # 고유 ν‚€
726
+ # μ²΄ν¬λ°•μŠ€ κ°’ λ³€κ²½ μ‹œ 콜백 μ‚¬μš© λŒ€μ‹ , 루프 ν›„ 비ꡐ λ°©μ‹μœΌλ‘œ 처리
727
+ if st.sidebar.checkbox(label_full, value=is_selected, key=checkbox_key):
728
+ # 체크된 경우 μž„μ‹œ λ¦¬μŠ€νŠΈμ— μΆ”κ°€
729
  selected_files_temp.append(file_id)
730
+
731
+ # μƒ˜ν”Œ 단어 및 μ‚­μ œ λ²„νŠΌ (μ—…λ‘œλ“œλœ νŒŒμΌμ—λ§Œ)
732
  with st.sidebar.expander("파일 정보 보기", expanded=False):
733
+ st.markdown(f"**μƒ˜ν”Œ 단어:** `{'`, `'.join(file_info['sample_words'])}`")
734
  if file_info['type'] == 'uploaded':
735
+ delete_button_key = f"del_{file_id}"
736
+ if st.button('πŸ—‘οΈ 이 파일 μ‚­μ œ', key=delete_button_key, help=f"'{file_info['name']}' νŒŒμΌμ„ 영ꡬ적으둜 μ‚­μ œν•©λ‹ˆλ‹€."):
737
+ with st.spinner(f"'{file_info['name']}' μ‚­μ œ 쀑..."):
738
+ if delete_file(file_id):
739
+ # μ‚­μ œ 성곡 μ‹œ, selected_files_tempμ—μ„œλ„ 제거 (ν•„μˆ˜)
740
+ if file_id in selected_files_temp:
741
+ selected_files_temp.remove(file_id)
742
+ # data_files μƒνƒœκ°€ λ³€κ²½λ˜μ—ˆμœΌλ―€λ‘œ μž¬μ‹€ν–‰ ν•„μš”
743
+ st.experimental_rerun()
744
+ else:
745
+ st.error("파일 μ‚­μ œμ— μ‹€νŒ¨ν–ˆμŠ΅λ‹ˆλ‹€.")
746
+ # st.sidebar.markdown("---") # ꡬ뢄선 제거 λ˜λŠ” μŠ€νƒ€μΌ μ‘°μ •
747
+
748
+ # --- μ€‘μš”: 선택 μƒνƒœ μ—…λ°μ΄νŠΈ ---
749
+ # ν˜„μž¬ μ²΄ν¬λ°•μŠ€ μƒνƒœ(selected_files_temp)와 μ„Έμ…˜ μƒνƒœ(st.session_state.selected_files)κ°€ λ‹€λ₯Ό λ•Œλ§Œ μ—…λ°μ΄νŠΈ
750
+ # μˆœμ„œμ— 상관없이 λΉ„κ΅ν•˜κΈ° μœ„ν•΄ μ •λ ¬ ν›„ 비ꡐ
751
  if sorted(selected_files_temp) != sorted(st.session_state.selected_files):
752
  st.session_state.selected_files = selected_files_temp
753
+ st.session_state.fig = None # 파일 선택 λ³€κ²½ μ‹œ κ·Έλž˜ν”„ μ΄ˆκΈ°ν™”
754
+ st.session_state.generate_clicked = False # 클릭 μƒνƒœλ„ 리셋
755
+ # 선택 λ³€κ²½ μ‹œ λ°”λ‘œ μž¬μ‹€ν–‰ν•˜μ—¬ UI 반영 (μ„ νƒμ μ΄μ§€λ§Œ μ‚¬μš©μž κ²½ν—˜ κ°œμ„ )
756
+ st.experimental_rerun()
757
 
758
  st.sidebar.divider()
759
 
760
+ # κ·Έλž˜ν”„ 생성 λ²„νŠΌ
761
+ # μ„ νƒλœ 파일이 μžˆμ„ λ•Œλ§Œ ν™œμ„±ν™”
762
  if st.session_state.selected_files:
763
  if st.sidebar.button('πŸ“Š κ·Έλž˜ν”„ 생성/μ—…λ°μ΄νŠΈ', key='generate_button', type="primary"):
764
+ # λ²„νŠΌ 클릭 μ‹œ, generate_clicked ν”Œλž˜κ·Έ μ„€μ •
765
+ # μ„ νƒλœ 파일이 μžˆλŠ”μ§€ λ‹€μ‹œ ν•œλ²ˆ 확인 (ν˜Ήμ‹œ λͺ¨λ₯Ό λ™μ‹œμ„± 문제 λ°©μ§€)
766
+ if st.session_state.selected_files:
767
+ st.session_state.generate_clicked = True
768
+ # μ—¬κΈ°μ„œ st.experimental_rerun() 호좜 제거! λ²„νŠΌ 클릭 μ‹œ μžλ™μœΌλ‘œ μž¬μ‹€ν–‰λ¨
769
+ else:
770
+ st.sidebar.warning('κ·Έλž˜ν”„λ₯Ό 생성할 νŒŒμΌμ„ λ¨Όμ € μ„ νƒν•΄μ£Όμ„Έμš”.')
771
+ st.session_state.generate_clicked = False # λ§Œμ•½μ„ μœ„ν•΄ 리셋
772
  else:
773
+ st.sidebar.warning('κ·Έλž˜ν”„λ₯Ό μƒμ„±ν•˜λ €λ©΄ μ΅œμ†Œ 1개 μ΄μƒμ˜ νŒŒμΌμ„ μ„ νƒν•΄μ£Όμ„Έμš”.')
774
 
775
  else:
776
+ st.sidebar.warning('μ‚¬μš© κ°€λŠ₯ν•œ 데이터 파일이 μ—†μŠ΅λ‹ˆλ‹€. νŒŒμΌμ„ μ—…λ‘œλ“œν•˜κ±°λ‚˜ `data` 폴더에 JSON νŒŒμΌμ„ μΆ”κ°€ν•˜μ„Έμš”.')
777
 
 
778
 
779
+ # μΊμ‹œ μ΄ˆκΈ°ν™” λ²„νŠΌ (항상 ν‘œμ‹œ)
780
  if st.sidebar.button('πŸ”„ μΊμ‹œ μ΄ˆκΈ°ν™”', key='clear_cache_button'):
781
  clear_cache()
782
 
 
783
  # --- 메인 μ½˜ν…μΈ  μ˜μ—­ ---
784
  st.header("πŸ“ˆ 3D 단어 λ„€νŠΈμ›Œν¬ μ‹œκ°ν™”")
785
 
786
  # κ·Έλž˜ν”„ ν‘œμ‹œ 둜직
787
+ # 1. μ„ νƒλœ 파일이 μžˆμ–΄μ•Ό 함
788
+ # 2. 'κ·Έλž˜ν”„ 생성' λ²„νŠΌμ΄ ν΄λ¦­λ˜μ—ˆκ±°λ‚˜ (generate_clicked == True)
789
+ # 3. 이미 μƒμ„±λœ κ·Έλž˜ν”„κ°€ μ„Έμ…˜ μƒνƒœμ— μžˆμ–΄μ•Ό 함 (st.session_state.fig is not None)
790
+
791
  if st.session_state.selected_files:
792
+ # κ·Έλž˜ν”„λ₯Ό 생성해야 ν•˜λŠ” 쑰건 : λ²„νŠΌ 클릭 ν”Œλž˜κ·Έκ°€ True μ΄κ±°λ‚˜, μž„κ³„κ°’/νŒŒμΌμ„ νƒ λ³€κ²½μœΌλ‘œ figκ°€ None이 된 경우
793
  should_generate_graph = st.session_state.generate_clicked or \
794
+ (st.session_state.fig is None and st.session_state.selected_files) # 파일 선택 ν›„ figκ°€ 없을 λ•Œ
795
 
796
+ if should_generate_graph:
797
+ with st.spinner('κ·Έλž˜ν”„ 생성 쀑... μž μ‹œλ§Œ κΈ°λ‹€λ €μ£Όμ„Έμš”.'):
798
  try:
799
+ # generate_graph ν•¨μˆ˜ 호좜
800
+ fig = generate_graph(st.session_state.selected_files, st.session_state.threshold)
801
+ # μ„±κ³΅μ μœΌλ‘œ μƒμ„±λ˜λ©΄ μ„Έμ…˜ μƒνƒœμ— μ €μž₯
802
+ st.session_state.fig = fig
803
+ # 생성 μ™„λ£Œ ν›„ 클릭 ν”Œλž˜κ·Έ 리셋
804
+ st.session_state.generate_clicked = False
 
 
 
805
  except Exception as e:
806
+ st.error(f"κ·Έλž˜ν”„ 생성 쀑 였λ₯˜ λ°œμƒ: {e}")
807
+ st.session_state.fig = None # 였λ₯˜ λ°œμƒ μ‹œ fig μ΄ˆκΈ°ν™”
808
+ st.session_state.generate_clicked = False # ν”Œλž˜κ·Έ 리셋
 
809
 
810
+ # μƒμ„±λœ κ·Έλž˜ν”„κ°€ μ„Έμ…˜ μƒνƒœμ— 있으면 ν‘œμ‹œ
811
  if st.session_state.get('fig') is not None:
812
  st.plotly_chart(st.session_state.fig, use_container_width=True)
813
 
814
  # ν˜„μž¬ κ·Έλž˜ν”„ 정보 ν‘œμ‹œ
815
  try:
816
+ selected_file_names = [st.session_state.data_files[fid]['name'] for fid in st.session_state.selected_files if fid in st.session_state.data_files]
817
+ total_word_count = sum(st.session_state.data_files[fid]['word_count'] for fid in st.session_state.selected_files if fid in st.session_state.data_files)
818
+ # μ‹€μ œ κ·Έλž˜ν”„μ˜ λ…Έλ“œ/μ—£μ§€ 수 κ°€μ Έμ˜€κΈ° (fig 객체 뢄석 ν•„μš”)
819
  num_nodes = len(st.session_state.fig.data[1].x) if len(st.session_state.fig.data) > 1 and hasattr(st.session_state.fig.data[1], 'x') else 0
820
  num_edges = len(st.session_state.fig.data[0].x) // 3 if len(st.session_state.fig.data) > 0 and hasattr(st.session_state.fig.data[0], 'x') and st.session_state.fig.data[0].x else 0
821
 
 
 
 
822
 
823
  st.info(f"""
824
  **ν˜„μž¬ κ·Έλž˜ν”„ 정보**
 
829
  except Exception as info_e:
830
  st.warning(f"κ·Έλž˜ν”„ 정보 ν‘œμ‹œ 쀑 였λ₯˜: {info_e}")
831
 
832
+
833
  # μ‚¬μš© μ„€λͺ…
834
  with st.expander("πŸ’‘ κ·Έλž˜ν”„ μ‘°μž‘ 방법"):
835
  st.markdown("""
836
+ - **ν™•λŒ€/μΆ•μ†Œ:** 마우슀 휠 슀크둀 λ˜λŠ” ν„°μΉ˜μŠ€ν¬λ¦°μ—μ„œ 두 손가락 μ‚¬μš©
837
  - **νšŒμ „:** 마우슀 μ™Όμͺ½ λ²„νŠΌ λˆ„λ₯Έ μƒνƒœλ‘œ λ“œλž˜κ·Έ
838
+ - **이동 (Pan):** 마우슀 였λ₯Έμͺ½ λ²„νŠΌ λˆ„λ₯Έ μƒνƒœλ‘œ λ“œλž˜κ·Έ λ˜λŠ” Shift + μ™Όμͺ½ λ²„νŠΌ λ“œλž˜κ·Έ
839
+ - **단어 정보 확인:** 마우슀 μ»€μ„œλ₯Ό 단어(마컀) μœ„μ— 올리면 단어 이름과 μ—°κ²°λœ λ‹€λ₯Έ λ‹¨μ–΄μ˜ 수λ₯Ό λ³Ό 수 μžˆμŠ΅λ‹ˆλ‹€.
840
+ - **νˆ΄λ°” μ‚¬μš©:** κ·Έλž˜ν”„ 우츑 μƒλ‹¨μ˜ νˆ΄λ°” μ•„μ΄μ½˜μ„ μ‚¬μš©ν•˜μ—¬ λ‹€μ–‘ν•œ 보기 μ˜΅μ…˜(λ‹€μš΄λ‘œλ“œ, ν™•λŒ€/μΆ•μ†Œ μ˜μ—­ μ§€μ • λ“±)을 ν™œμš©ν•  수 μžˆμŠ΅λ‹ˆλ‹€.
841
  """)
842
+ elif not should_generate_graph and not st.session_state.selected_files:
843
+ st.info("πŸ‘ˆ μ‚¬μ΄λ“œλ°”μ—μ„œ 뢄석할 데이터 νŒŒμΌμ„ μ„ νƒν•΄μ£Όμ„Έμš”.")
844
+ elif not should_generate_graph and st.session_state.selected_files and st.session_state.fig is None:
845
+ # νŒŒμΌμ€ μ„ νƒν–ˆμ§€λ§Œ 아직 생성 λ²„νŠΌ μ•ˆ λˆ„λ¦„ or 생성 μ‹€νŒ¨
846
  st.info("πŸ‘ˆ μ‚¬μ΄λ“œλ°”μ—μ„œ 'πŸ“Š κ·Έλž˜ν”„ 생성/μ—…λ°μ΄νŠΈ' λ²„νŠΌμ„ ν΄λ¦­ν•˜μ—¬ μ‹œκ°ν™”λ₯Ό μ‹œμž‘ν•˜μ„Έμš”.")
847
 
 
848
  elif not st.session_state.data_files:
849
  st.warning("ν‘œμ‹œν•  데이터 파일이 μ—†μŠ΅λ‹ˆλ‹€. νŒŒμΌμ„ μ—…λ‘œλ“œν•˜κ±°λ‚˜ `data` 폴더에 μœ νš¨ν•œ JSON νŒŒμΌμ„ μΆ”κ°€ν•˜μ„Έμš”.")
850
+ else:
851
+ # data_filesλŠ” μžˆμ§€λ§Œ selected_filesκ°€ μ—†λŠ” 경우
852
  st.info("πŸ‘ˆ μ‚¬μ΄λ“œλ°”μ—μ„œ 뢄석할 데이터 νŒŒμΌμ„ μ„ νƒν•΄μ£Όμ„Έμš”.")
853
 
 
854
  # --- ν•˜λ‹¨ 정보 μ„Ήμ…˜ ---
855
  st.divider()
856
+
857
  with st.expander("ℹ️ 이 μ‹œκ°ν™” 도ꡬ에 λŒ€ν•˜μ—¬"):
858
+ st.markdown("""
859
  이 λ„κ΅¬λŠ” λ‹€μŒκ³Ό 같은 과정을 톡해 ν•œκ΅­μ–΄ 단어 λ„€νŠΈμ›Œν¬λ₯Ό μ‹œκ°ν™”ν•©λ‹ˆλ‹€:
860
 
861
  1. **데이터 λ‘œλ”©:** μ‚¬μš©μžκ°€ μ œκ³΅ν•œ JSON νŒŒμΌμ—μ„œ 'word' ν•„λ“œλ₯Ό κ°€μ§„ 단어 λͺ©λ‘μ„ μΆ”μΆœν•©λ‹ˆλ‹€.
862
+ 2. **단어 μž„λ² λ”©:** 각 단어λ₯Ό 고차원 벑터 곡간에 ν‘œν˜„ν•©λ‹ˆλ‹€. ν˜„μž¬λŠ” **문자 ꡬ성 기반 TF-IDF μŠ€νƒ€μΌ μž„λ² λ”©**을 μ‚¬μš©ν•˜μ—¬, 단어λ₯Ό μ΄λ£¨λŠ” λ¬Έμžλ“€μ˜ λΉˆλ„λ₯Ό 기반으둜 벑터λ₯Ό μƒμ„±ν•©λ‹ˆλ‹€. (μΆ”ν›„ Word2Vec, FastText λ“± 사전 ν›ˆλ ¨λœ λͺ¨λΈ μ‚¬μš© κ°€λŠ₯)
863
+ 3. **차원 μΆ•μ†Œ:** 고차원 μž„λ² λ”© 벑터λ₯Ό μ‹œκ°ν™” κ°€λŠ₯ν•œ 3차원 κ³΅κ°„μœΌλ‘œ μΆ•μ†Œν•©λ‹ˆλ‹€. **t-SNE(t-Distributed Stochastic Neighbor Embedding)** μ•Œκ³ λ¦¬μ¦˜μ„ μ‚¬μš©ν•˜μ—¬ λ³΅μž‘ν•œ 데이터 ꡬ쑰λ₯Ό μœ μ§€ν•˜λ©΄μ„œ 차원을 μ€„μž…λ‹ˆλ‹€. (단어 μˆ˜κ°€ 적을 경우 PCA μ‚¬μš©)
864
+ 4. **μœ μ‚¬λ„ 계산:** 3D κ³΅κ°„μœΌλ‘œ μΆ•μ†Œν•˜κΈ° μ „μ˜ 원본 μž„λ² λ”© 벑터 κ°„μ˜ **코사인 μœ μ‚¬λ„(Cosine Similarity)**λ₯Ό κ³„μ‚°ν•˜μ—¬ 단어 쌍의 의미적(μ—¬κΈ°μ„œλŠ” ꡬ성적) μœ μ‚¬μ„±μ„ μΈ‘μ •ν•©λ‹ˆλ‹€.
865
+ 5. **κ·Έλž˜ν”„ 생성:** μ„€μ •λœ **μœ μ‚¬λ„ μž„κ³„κ°’(Threshold)** 이상인 단어 μŒλ“€μ„ μ—°κ²°μ„ (μ—£μ§€)으둜 이어 λ„€νŠΈμ›Œν¬ κ·Έλž˜ν”„λ₯Ό κ΅¬μ„±ν•©λ‹ˆλ‹€. 각 λ‹¨μ–΄λŠ” λ…Έλ“œ(점)둜 ν‘œμ‹œλ©λ‹ˆλ‹€.
866
+ 6. **3D μ‹œκ°ν™”:** **Plotly 라이브러리**λ₯Ό μ‚¬μš©ν•˜μ—¬ μƒμ„±λœ λ„€νŠΈμ›Œν¬ κ·Έλž˜ν”„λ₯Ό μΈν„°λž™ν‹°λΈŒν•œ 3D 곡간에 μ‹œκ°ν™”ν•©λ‹ˆλ‹€. λ…Έλ“œμ˜ μœ„μΉ˜λŠ” t-SNE κ²°κ³Ό μ’Œν‘œλ₯Ό λ”°λ₯΄λ©°, μƒ‰μƒμ΄λ‚˜ ν¬κΈ°λŠ” ZμΆ• κ°’μ΄λ‚˜ μ—°κ²° 수(degree) 등을 λ°˜μ˜ν•  수 μžˆμŠ΅λ‹ˆλ‹€.
867
+
868
+ 이λ₯Ό 톡해 단어듀이 μ„œλ‘œ μ–Όλ§ˆλ‚˜ μœ μ‚¬ν•œμ§€μ— 따라 ꡰ집을 μ΄λ£¨κ±°λ‚˜ μ—°κ²°λ˜λŠ” νŒ¨ν„΄μ„ μ‹œκ°μ μœΌλ‘œ 탐색할 수 μžˆμŠ΅λ‹ˆλ‹€.
869
+ """)
870
+
871
+ with st.expander("πŸ“‹ JSON 파일 ν˜•μ‹ μ•ˆλ‚΄"):
872
+ st.markdown("""
873
+ μ—…λ‘œλ“œν•˜κ±°λ‚˜ `data` 폴더에 λ„£λŠ” JSON νŒŒμΌμ€ **UTF-8 인코딩**이어야 ν•˜λ©°, λ‹€μŒκ³Ό 같은 ν˜•μ‹μ„ 따라야 ν•©λ‹ˆλ‹€:
874
+
875
+ ```json
876
+ [
877
+ {
878
+ "word": "학ꡐ"
879
+ },
880
+ {
881
+ "word": "μ„ μƒλ‹˜"
882
+ },
883
+ {
884
+ "word": "학생"
885
+ },
886
+ {
887
+ "word": "ꡐ싀"
888
+ },
889
+ {
890
+ "word": "컴퓨터",
891
+ "description": "이 ν•„λ“œλŠ” λ¬΄μ‹œλ©λ‹ˆλ‹€"
892
+ }
893
+ ]
894
+ ```
895
+
896
+ - 파일의 μ΅œμƒμœ„ ꡬ쑰��� **λ°°μ—΄(List)**이어야 ν•©λ‹ˆλ‹€ (`[...]`).
897
+ - λ°°μ—΄μ˜ 각 μš”μ†ŒλŠ” **객체(Dictionary)**μ—¬μ•Ό ν•©λ‹ˆλ‹€ (`{...}`).
898
+ - 각 κ°μ²΄λŠ” λ°˜λ“œμ‹œ `"word"`λΌλŠ” ν‚€λ₯Ό 포함해야 ν•˜λ©°, κ·Έ 값은 뢄석할 **ν•œκ΅­μ–΄ 단어 λ¬Έμžμ—΄**이어야 ν•©λ‹ˆλ‹€.
899
+ - `"word"` μ™Έμ˜ λ‹€λ₯Έ ν‚€κ°€ μžˆμ–΄λ„ λ¬΄λ°©ν•˜λ‚˜, ν˜„μž¬ λ²„μ „μ—μ„œλŠ” μ‚¬μš©λ˜μ§€ μ•Šκ³  λ¬΄μ‹œλ©λ‹ˆλ‹€.
900
+ - 파일 인코딩이 UTF-8이 μ•„λ‹Œ 경우 ν•œκΈ€μ΄ κΉ¨μ§€κ±°λ‚˜ 였λ₯˜κ°€ λ°œμƒν•  수 μžˆμŠ΅λ‹ˆλ‹€.
901
  """)