QIDNLF commited on
Commit
94459ae
ยท
verified ยท
1 Parent(s): c5aa224

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +206 -211
app.py CHANGED
@@ -6,6 +6,8 @@ import re
6
  import base64
7
  import difflib
8
  import traceback
 
 
9
 
10
  # --------------------------
11
  # 1๏ธโƒฃ ์ €์žฅ์†Œ ์„ค์ •
@@ -17,39 +19,184 @@ if not os.path.exists(UPLOAD_DIR):
17
  db_registry = []
18
 
19
  # --------------------------
20
- # ์œ ํ‹ธ
21
  # --------------------------
22
- def blob_to_base64_html(blob_data):
23
- if blob_data is None or pd.isna(blob_data):
24
- return ""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
25
  try:
26
- if isinstance(blob_data, (bytes, bytearray)):
27
- encoded = base64.b64encode(blob_data).decode('utf-8')
28
- return f'''
29
- <img src="data:image/png;base64,{encoded}"
30
- style="width: 50%; max-height: 400px; object-fit: contain;
31
- cursor: zoom-in; border: 1px solid #ccc; border-radius: 4px;
32
- padding: 2px; background-color: white; margin: 5px 0; display: block;"
33
- onclick="window.open(this.src)">
34
- '''
35
- return str(blob_data)
36
- except Exception as e:
37
- return str(blob_data)
 
 
 
 
 
 
38
 
39
- # ๐Ÿš€ [ํ•ต์‹ฌ ์ถ”๊ฐ€] ํ…์ŠคํŠธ ๋””์ฝ”๋”ฉ ์—๋Ÿฌ ๋ฐฉ์ง€์šฉ ํŒฉํ† ๋ฆฌ ํ•จ์ˆ˜
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
40
  def safe_text_factory(x):
41
- """ํ…์ŠคํŠธ ๋””์ฝ”๋”ฉ์„ ์‹œ๋„ํ•˜๊ณ , ์‹คํŒจํ•˜๋ฉด(PNG ๋“ฑ ์ด๋ฏธ์ง€์ผ ๊ฒฝ์šฐ) ์›๋ณธ ๋ฐ”์ดํŠธ๋ฅผ ๊ทธ๋Œ€๋กœ ๋ฐ˜ํ™˜"""
42
  try:
43
  return x.decode('utf-8')
44
  except UnicodeDecodeError:
45
  return x
46
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
47
  def natural_sort_key(s):
 
48
  if s is None:
49
  return []
50
- return [int(t) if t.isdigit() else t.lower()
51
- for t in re.split(r'(\d+)', str(s))]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
52
 
 
 
 
53
  def refresh_registry_data():
54
  global db_registry
55
  db_registry = []
@@ -67,6 +214,7 @@ def refresh_registry_data():
67
 
68
  return sorted(list(set([d["standard"] for d in db_registry])))
69
 
 
70
  # --------------------------
71
  # ๋“œ๋กญ๋‹ค์šด
72
  # --------------------------
@@ -76,9 +224,7 @@ def on_load():
76
  def update_version_dd(standard):
77
  if not standard:
78
  return gr.Dropdown(choices=[])
79
- versions = sorted(set(
80
- d["version"] for d in db_registry if d["standard"] == standard
81
- ))
82
  return gr.Dropdown(choices=versions)
83
 
84
  def update_category_dd(standard, version):
@@ -89,10 +235,7 @@ def update_category_dd(standard, version):
89
 
90
  try:
91
  target = next(d for d in db_registry if d["standard"] == standard and d["version"] == version)
92
- conn = sqlite3.connect(target["path"])
93
-
94
- # ๐Ÿš€ [ํ•ต์‹ฌ ์ถ”๊ฐ€] ์นดํ…Œ๊ณ ๋ฆฌ ๋ถˆ๋Ÿฌ์˜ฌ ๋•Œ๋„ ์ด๋ฏธ์ง€ ์—๋Ÿฌ ์ฐจ๋‹จ
95
- conn.text_factory = safe_text_factory
96
 
97
  tables = pd.read_sql("SELECT name FROM sqlite_master WHERE type='table';", conn)['name'].tolist()
98
  main_t = f"{standard}_{version}"
@@ -102,35 +245,26 @@ def update_category_dd(standard, version):
102
  cols = pd.read_sql(f"PRAGMA table_info([{main_t}])", conn)['name'].tolist()
103
  lower = [c.lower() for c in cols]
104
 
105
- # 1. ์ฑ•ํ„ฐ/์นดํ…Œ๊ณ ๋ฆฌ ๊ธฐ์ค€ ๋กœ์ง (๊ธฐ์กด ์œ ์ง€)
106
  if 'chapter' in lower and 'category' in lower:
107
  ch = cols[lower.index('chapter')]
108
  ca = cols[lower.index('category')]
109
-
110
  df = pd.read_sql(f"SELECT DISTINCT [{ch}], [{ca}] FROM [{main_t}]", conn)
111
-
112
  for _, r in df.iterrows():
113
  choices.append(f"{str(r[ch]).strip()}.{str(r[ca]).strip()}")
114
 
115
- # 2. ํ…Œ์ด๋ธ” ์ด๋ฆ„ ๊น”๋”ํ•˜๊ฒŒ ์ž๋ฅด๊ธฐ (๊ฐ•๋ ฅํ•œ ์ •๊ทœ์‹ ์ ์šฉ)
116
  pattern = re.compile(f"^{standard}[_\\s-]*{version}[_\\s-]*", re.IGNORECASE)
117
-
118
  for t in tables:
119
  if t == main_t:
120
  continue
121
-
122
- # ๋Œ€์†Œ๋ฌธ์ž, ์–ธ๋”๋ฐ”, ๊ณต๋ฐฑ์„ ์ฐฐ๋–ก๊ฐ™์ด ๋ฌด์‹œํ•˜๊ณ  ๊ธฐ์ค€ ์ ‘๋‘์‚ฌ๋ฅผ ๋‚ ๋ ค๋ฒ„๋ฆผ
123
  short_name = pattern.sub("", t).strip(" _")
124
  choices.append(short_name)
125
 
126
- conn.close()
127
-
128
- except Exception as e:
129
- print(f"[Error in update_category_dd] standard: {standard}, version: {version}")
130
  traceback.print_exc()
131
 
132
  return gr.Dropdown(choices=choices)
133
 
 
134
  # --------------------------
135
  # ์ดˆ๊ธฐํ™”
136
  # --------------------------
@@ -140,74 +274,35 @@ def reset_base():
140
  def reset_comp():
141
  return None, None, None
142
 
143
- # --------------------------
144
- # diff (ํ•˜์ด๋ผ์ดํŒ… ๋กœ์ง)
145
- # --------------------------
146
- def highlight_diff(base, comp):
147
- base = "" if pd.isna(base) else str(base)
148
- comp = "" if pd.isna(comp) else str(comp)
149
-
150
- b_words = base.split()
151
- c_words = comp.split()
152
-
153
- d = list(difflib.ndiff(b_words, c_words))
154
-
155
- b_res, c_res = []
156
- i = 0
157
-
158
- while i < len(d):
159
- code = d[i][0]
160
- word = d[i][2:]
161
-
162
- if code == ' ':
163
- b_res.append(word)
164
- c_res.append(word)
165
-
166
- elif code == '-' and i+1 < len(d) and d[i+1][0] == '+':
167
- new_word = d[i+1][2:]
168
- b_res.append(f"<span style='color:#ff4d4f;font-weight:600'>{word}</span>")
169
- c_res.append(f"<span style='color:#ff4d4f;font-weight:600'>{new_word}</span>")
170
- i += 1
171
-
172
- elif code == '-':
173
- b_res.append(f"<span style='color:#ff4d4f;font-weight:600'>{word}</span>")
174
-
175
- elif code == '+':
176
- c_res.append(f"<span style='color:#2ecc71;font-weight:600'>{word}</span>")
177
-
178
- i += 1
179
-
180
- return " ".join(b_res), " ".join(c_res)
181
 
182
  # --------------------------
183
- # ๋ฐ์ดํ„ฐ ์กฐํšŒ
184
  # --------------------------
185
  def display_data(standard, version, selection):
186
  if not all([standard, version, selection]):
187
  return pd.DataFrame({"Info": ["์„ ํƒ ํ•„์š”"]})
188
 
 
 
 
 
 
 
189
  try:
190
  target = next(d for d in db_registry if d["standard"] == standard and d["version"] == version)
191
- conn = sqlite3.connect(target["path"])
192
-
193
- # ๐Ÿš€ [ํ•ต์‹ฌ ์ถ”๊ฐ€] ๋ฐ์ดํ„ฐ ์กฐํšŒ ์‹œ ์ด๋ฏธ์ง€ ์—๋Ÿฌ ์™„๋ฒฝ ์ฐจ๋‹จ!
194
- conn.text_factory = safe_text_factory
195
 
196
  tables = pd.read_sql(
197
- "SELECT name FROM sqlite_master WHERE type='table';",
198
- conn
199
  )['name'].tolist()
200
 
201
  main_t = f"{standard}_{version}"
202
  if main_t not in tables:
203
  main_t = tables[0]
204
 
205
- # --------------------------
206
- # ๐Ÿ”น ํ…Œ์ด๋ธ” reverse lookup
207
- # --------------------------
208
  full_table_name = None
209
  pattern = re.compile(f"^{standard}[_\\s-]*{version}[_\\s-]*", re.IGNORECASE)
210
-
211
  for t in tables:
212
  if t == selection:
213
  full_table_name = t
@@ -217,31 +312,23 @@ def display_data(standard, version, selection):
217
  full_table_name = t
218
  break
219
 
220
- # --------------------------
221
- # ๐Ÿ”น TableA/B ์„ ํƒ
222
- # --------------------------
223
  if full_table_name:
224
  cursor = conn.cursor()
225
  cursor.execute(f"SELECT * FROM [{full_table_name}]")
226
-
227
  rows = cursor.fetchall()
228
  cols = [desc[0] for desc in cursor.description]
229
 
230
  df = pd.DataFrame(rows, columns=cols)
231
-
232
  df.columns = [c.strip() for c in df.columns]
233
  df = df.drop(columns=[c for c in df.columns if c.lower() == "version"], errors="ignore")
234
  df.columns = [c.replace("_", " ").title() for c in df.columns]
 
235
 
236
- for col in df.columns:
237
- df[col] = df[col].apply(blob_to_base64_html)
238
 
239
- conn.close()
240
- return df
241
-
242
- # --------------------------
243
- # ๐Ÿ”น ๋ฉ”์ธ ํ…Œ์ด๋ธ” ๊ตฌ์กฐ
244
- # --------------------------
245
  cursor = conn.cursor()
246
  cursor.execute(f"PRAGMA table_info([{main_t}])")
247
  cols_info = cursor.fetchall()
@@ -255,85 +342,48 @@ def display_data(standard, version, selection):
255
  elif lc == "category":
256
  real_cat = c
257
 
258
- # --------------------------
259
- # ๐Ÿ”น ALL ์„ ํƒ
260
- # --------------------------
261
  if selection == "ALL":
262
- # ๐Ÿš€ [์ˆ˜์ •] ํ…์ŠคํŠธ ํŒฉํ† ๋ฆฌ๋กœ ์—๋Ÿฌ๋ฅผ ์žก์•˜์œผ๋ฏ€๋กœ, ๋ถˆํ•„์š”ํ•œ ํ•„ํ„ฐ ์กฐ๊ฑด ์‚ญ์ œ
263
  cursor.execute(f"SELECT * FROM [{main_t}]")
264
 
265
- # --------------------------
266
- # ๐Ÿ”น chapter.category
267
- # --------------------------
268
  elif "." in selection and real_ch and real_cat:
269
  ch, ca = selection.split(".", 1)
270
-
271
  query = f"""
272
  SELECT * FROM [{main_t}]
273
  WHERE REPLACE(TRIM(CAST([{real_ch}] AS TEXT)), ' ', '') = ?
274
  AND REPLACE(TRIM(CAST([{real_cat}] AS TEXT)), ' ', '') = ?
275
  """
276
-
277
  cursor.execute(query, (ch.replace(" ", ""), ca.replace(" ", "")))
278
 
279
- # --------------------------
280
- # ๐Ÿ”น fallback
281
- # --------------------------
282
  else:
283
  cursor.execute(f"SELECT * FROM [{main_t}]")
284
 
285
  rows = cursor.fetchall()
286
  df = pd.DataFrame(rows, columns=cols)
287
-
288
- # --------------------------
289
- # ๐Ÿ”น ํ›„์ฒ˜๋ฆฌ
290
- # --------------------------
291
  df.columns = [c.lower().strip() for c in df.columns]
292
 
293
  if 'section' in df.columns and 'description' in df.columns:
294
  df = df[['section', 'description']]
295
 
296
- # ๐Ÿ”ฅ ํ•ต์‹ฌ: blob โ†’ ์ด๋ฏธ์ง€ ๋ณ€ํ™˜ (์‚ญ์ œ X)
297
- for col in df.columns:
298
- df[col] = df[col].apply(blob_to_base64_html)
299
 
300
- conn.close()
301
- return df
302
 
303
  except Exception as e:
304
- print(f"[Error in display_data]")
305
  traceback.print_exc()
306
  return pd.DataFrame({"Error": [str(e)]})
307
 
 
308
  # --------------------------
309
- # ํ†ตํ•ฉ ์กฐํšŒ (apply๋กœ ์†๋„ ์ตœ์ ํ™”)
310
  # --------------------------
311
- def apply_diff_row(row, base_col, comp_col):
312
- """Pandas apply๋ฅผ ์œ„ํ•œ Diff ์—ฐ์‚ฐ ๋ž˜ํผ ํ•จ์ˆ˜"""
313
- base_val = str(row.get(base_col, ""))
314
- comp_val = str(row.get(comp_col, ""))
315
-
316
- # 1. ์ด๋ฏธ์ง€๊ฐ€ ํฌํ•จ๋œ ๊ฒฝ์šฐ diff ์ƒ๋žต
317
- if "<img" in base_val or "<img" in comp_val:
318
- return pd.Series([base_val, comp_val])
319
-
320
- # 2. [ํ•ต์‹ฌ] ๋‘ ํ…์ŠคํŠธ๊ฐ€ ์™„์ „ํžˆ ๋˜‘๊ฐ™์œผ๋ฉด ๋ฌด๊ฑฐ์šด diff ์—ฐ์‚ฐ ์ƒ๋žต (Fast-path)
321
- if base_val == comp_val:
322
- return pd.Series([base_val, comp_val])
323
-
324
- # 3. ๋‚ด์šฉ์ด ์„œ๋กœ ๋‹ค๋ฅผ ๋•Œ๋งŒ ๋‹จ์–ด ๋‹จ์œ„ diff ์—ฐ์‚ฐ ์ˆ˜ํ–‰
325
- if base_val and comp_val:
326
- b, c = highlight_diff(base_val, comp_val)
327
- return pd.Series([b, c])
328
-
329
- return pd.Series([base_val, comp_val])
330
-
331
-
332
  def unified_search(bs, bv, bc, cs, cv, cc):
 
333
  if bs and bv and bc and not (cs and cv and cc):
334
  df = display_data(bs, bv, bc)
335
  return df.rename(columns={"section": "Section", "description": "Description"})
336
 
 
337
  if bs and bv and bc and cs and cv and cc:
338
  df_base = display_data(bs, bv, bc)
339
  df_comp = display_data(cs, cv, cc)
@@ -341,52 +391,32 @@ def unified_search(bs, bv, bc, cs, cv, cc):
341
  if 'section' not in df_base.columns or 'section' not in df_comp.columns:
342
  return pd.DataFrame({"Error": ["section ์—†์Œ"]})
343
 
344
- df_base = df_base.rename(columns={
345
- "section": f"Section_{bv}",
346
- "description": f"Description_{bv}"
347
- })
348
-
349
- df_comp = df_comp.rename(columns={
350
- "section": f"Section_{cv}",
351
- "description": f"Description_{cv}"
352
- })
353
 
354
  base_sec = f"Section_{bv}"
355
  comp_sec = f"Section_{cv}"
356
  base_col = f"Description_{bv}"
357
  comp_col = f"Description_{cv}"
358
 
359
- merged = pd.merge(
360
- df_base,
361
- df_comp,
362
- left_on=base_sec,
363
- right_on=comp_sec,
364
- how="outer"
365
- )
366
-
367
- # NaN ๊ฐ’์„ ๋นˆ ๋ฌธ์ž์—ด๋กœ ์ฒ˜๋ฆฌ
368
  merged = merged.fillna("")
369
 
370
- # ์ตœ์ ํ™” ํฌ์ธํŠธ: iterrows() ๋Œ€์‹  apply() ์‚ฌ์šฉ
371
  merged[[base_col, comp_col]] = merged.apply(
372
- lambda row: apply_diff_row(row, base_col, comp_col),
373
- axis=1
374
- )
375
-
376
- # ์ •๋ ฌ ๊ฐœ์„ 
377
- merged["__sort_key"] = merged[base_sec].where(
378
- merged[base_sec] != "", merged[comp_sec]
379
  )
380
 
 
381
  merged = merged.sort_values(
382
  by="__sort_key",
383
- key=lambda col: col.map(natural_sort_key)
384
  ).drop(columns="__sort_key")
385
 
386
  return merged
387
 
388
  return pd.DataFrame({"Info": ["์„ ํƒ ํ•„์š”"]})
389
 
 
390
  # --------------------------
391
  # UI
392
  # --------------------------
@@ -430,6 +460,7 @@ with gr.Blocks() as demo:
430
  base_reset_btn.click(reset_base, None, [base_standard, base_version, base_category])
431
  comp_reset_btn.click(reset_comp, None, [comp_standard, comp_version, comp_category])
432
 
 
433
  # --------------------------
434
  # CSS
435
  # --------------------------
@@ -438,63 +469,27 @@ table {
438
  table-layout: fixed !important;
439
  width: 100% !important;
440
  }
441
-
442
- /* -------------------------- */
443
- /* 2์ปฌ๋Ÿผ */
444
- /* -------------------------- */
445
  table th:nth-last-child(2):first-child, table td:nth-last-child(2):first-child { width: 15% !important; }
446
  table th:nth-last-child(1), table td:nth-last-child(1) { width: 85% !important; }
447
-
448
- /* -------------------------- */
449
- /* 4์ปฌ๋Ÿผ */
450
- /* -------------------------- */
451
  table th:nth-child(1):nth-last-child(4), table td:nth-child(1):nth-last-child(4) { width: 7% !important; }
452
  table th:nth-child(2):nth-last-child(3), table td:nth-child(2):nth-last-child(3) { width: 43% !important; }
453
  table th:nth-child(3):nth-last-child(2), table td:nth-child(3):nth-last-child(2) { width: 7% !important; }
454
  table th:nth-child(4):nth-last-child(1), table td:nth-child(4):nth-last-child(1) { width: 43% !important; }
455
-
456
- /* -------------------------- */
457
- /* 5์ปฌ๋Ÿผ */
458
- /* -------------------------- */
459
  table th:nth-child(1):nth-last-child(5), table td:nth-child(1):nth-last-child(5) { width: 15% !important; }
460
  table th:nth-child(2):nth-last-child(4), table td:nth-child(2):nth-last-child(4) { width: 10% !important; }
461
  table th:nth-child(3):nth-last-child(3), table td:nth-child(3):nth-last-child(3) { width: 30% !important; }
462
  table th:nth-child(4):nth-last-child(2), table td:nth-child(4):nth-last-child(2) { width: 10% !important; }
463
  table th:nth-child(5):nth-last-child(1), table td:nth-child(5):nth-last-child(1) { width: 35% !important; }
464
-
465
- /* -------------------------- */
466
- /* 6์ปฌ๋Ÿผ */
467
- /* -------------------------- */
468
  table th:nth-child(1):nth-last-child(6), table td:nth-child(1):nth-last-child(6) { width: 8% !important; }
469
  table th:nth-child(2):nth-last-child(5), table td:nth-child(2):nth-last-child(5) { width: 25% !important; }
470
  table th:nth-child(3):nth-last-child(4), table td:nth-child(3):nth-last-child(4) { width: 8% !important; }
471
  table th:nth-child(4):nth-last-child(3), table td:nth-child(4):nth-last-child(3) { width: 25% !important; }
472
  table th:nth-child(5):nth-last-child(2), table td:nth-child(5):nth-last-child(2) { width: 8% !important; }
473
  table th:nth-child(6):nth-last-child(1), table td:nth-child(6):nth-last-child(1) { width: 26% !important; }
474
-
475
- /* -------------------------- */
476
- /* ๊ธฐ๋ณธ ํ…Œ์ด๋ธ” & ์Šคํฌ๋กค ์„ค์ • */
477
- /* -------------------------- */
478
- thead th {
479
- position: sticky;
480
- top: 0;
481
- background: white;
482
- z-index: 10;
483
- }
484
- .dataframe {
485
- max-height: none !important;
486
- overflow-y: visible !important;
487
- }
488
- .dataframe > div {
489
- max-height: none !important;
490
- overflow: visible !important;
491
- }
492
- td {
493
- white-space: pre-wrap !important;
494
- word-break: break-word !important;
495
- line-height: 1.6;
496
- padding: 10px;
497
- }
498
  """
499
 
500
  if __name__ == "__main__":
 
6
  import base64
7
  import difflib
8
  import traceback
9
+ from functools import lru_cache
10
+ from threading import Lock
11
 
12
  # --------------------------
13
  # 1๏ธโƒฃ ์ €์žฅ์†Œ ์„ค์ •
 
19
  db_registry = []
20
 
21
  # --------------------------
22
+ # 2๏ธโƒฃ DB ์—ฐ๊ฒฐ ์บ์‹ฑ (ํ•ต์‹ฌ ๊ฐœ์„ )
23
  # --------------------------
24
+ _conn_cache: dict[str, sqlite3.Connection] = {}
25
+ _conn_lock = Lock()
26
+
27
+ def get_connection(db_path: str) -> sqlite3.Connection:
28
+ """
29
+ ๊ฐ™์€ ๊ฒฝ๋กœ์˜ DB๋Š” ์—ฐ๊ฒฐ์„ ์žฌ์‚ฌ์šฉ.
30
+ DB ํŒŒ์ผ์ด ๋ณ€๊ฒฝ๋˜์—ˆ์„ ๊ฒฝ์šฐ(mtime ๋ณ€ํ™”)๋ฅผ ๊ฐ์ง€ํ•ด ์ž๋™์œผ๋กœ ์žฌ์—ฐ๊ฒฐ.
31
+ """
32
+ mtime = os.path.getmtime(db_path)
33
+ cache_key = f"{db_path}::{mtime}"
34
+
35
+ with _conn_lock:
36
+ if cache_key not in _conn_cache:
37
+ # ๊ธฐ์กด ์—ฐ๊ฒฐ ์ •๋ฆฌ (๊ฐ™์€ path์˜ ๊ตฌ๋ฒ„์ „ ์—ฐ๊ฒฐ ์ œ๊ฑฐ)
38
+ stale = [k for k in _conn_cache if k.startswith(db_path + "::")]
39
+ for k in stale:
40
+ try:
41
+ _conn_cache[k].close()
42
+ except Exception:
43
+ pass
44
+ del _conn_cache[k]
45
+
46
+ conn = sqlite3.connect(db_path, check_same_thread=False)
47
+ conn.text_factory = safe_text_factory
48
+
49
+ # ์ธ๋ฑ์Šค ์ž๋™ ์ƒ์„ฑ (chapter, category, section ์ปฌ๋Ÿผ ๋Œ€์ƒ)
50
+ _ensure_indexes(conn, db_path)
51
+
52
+ _conn_cache[cache_key] = conn
53
+
54
+ return _conn_cache[cache_key]
55
+
56
+
57
+ def _ensure_indexes(conn: sqlite3.Connection, db_path: str):
58
+ """์ž์ฃผ ์กฐํšŒ๋˜๋Š” ์ปฌ๋Ÿผ์— ์ธ๋ฑ์Šค๊ฐ€ ์—†์œผ๋ฉด ์ž๋™ ์ƒ์„ฑ."""
59
  try:
60
+ tables = pd.read_sql(
61
+ "SELECT name FROM sqlite_master WHERE type='table';", conn
62
+ )['name'].tolist()
63
+
64
+ for table in tables:
65
+ cols_df = pd.read_sql(f"PRAGMA table_info([{table}])", conn)
66
+ cols = [c.lower() for c in cols_df['name'].tolist()]
67
+
68
+ for target_col in ['chapter', 'category', 'section']:
69
+ if target_col in cols:
70
+ real_col = cols_df['name'].tolist()[cols.index(target_col)]
71
+ idx_name = f"idx_{table}_{target_col}"
72
+ conn.execute(
73
+ f"CREATE INDEX IF NOT EXISTS [{idx_name}] ON [{table}] ([{real_col}])"
74
+ )
75
+ conn.commit()
76
+ except Exception:
77
+ pass # ์ธ๋ฑ์Šค ์ƒ์„ฑ ์‹คํŒจ๋Š” ๋ฌด์‹œ (read-only DB ๋“ฑ ์˜ˆ์™ธ ์ƒํ™ฉ ๋Œ€์‘)
78
 
79
+
80
+ # --------------------------
81
+ # 3๏ธโƒฃ ์ฟผ๋ฆฌ ๊ฒฐ๊ณผ ์บ์‹ฑ
82
+ # --------------------------
83
+ _query_cache: dict[str, pd.DataFrame] = {}
84
+
85
+ def _cache_key(*args) -> str:
86
+ return "::".join(str(a) for a in args)
87
+
88
+ def get_cached_df(key: str):
89
+ return _query_cache.get(key)
90
+
91
+ def set_cached_df(key: str, df: pd.DataFrame):
92
+ # ์บ์‹œ๊ฐ€ ๋„ˆ๋ฌด ์ปค์ง€์ง€ ์•Š๋„๋ก 50๊ฐœ ์ดˆ๊ณผ ์‹œ ๊ฐ€์žฅ ์˜ค๋ž˜๋œ ํ•ญ๋ชฉ ์ œ๊ฑฐ
93
+ if len(_query_cache) >= 50:
94
+ oldest = next(iter(_query_cache))
95
+ del _query_cache[oldest]
96
+ _query_cache[key] = df
97
+
98
+
99
+ # --------------------------
100
+ # ์œ ํ‹ธ
101
+ # --------------------------
102
  def safe_text_factory(x):
 
103
  try:
104
  return x.decode('utf-8')
105
  except UnicodeDecodeError:
106
  return x
107
 
108
+
109
+ def blob_to_base64_html(blob_data):
110
+ if blob_data is None or (isinstance(blob_data, float) and pd.isna(blob_data)):
111
+ return ""
112
+ if isinstance(blob_data, (bytes, bytearray)):
113
+ encoded = base64.b64encode(blob_data).decode('utf-8')
114
+ return (
115
+ f'<img src="data:image/png;base64,{encoded}" '
116
+ f'style="width:50%;max-height:400px;object-fit:contain;'
117
+ f'cursor:zoom-in;border:1px solid #ccc;border-radius:4px;'
118
+ f'padding:2px;background-color:white;margin:5px 0;display:block;" '
119
+ f'onclick="window.open(this.src)">'
120
+ )
121
+ return str(blob_data)
122
+
123
+
124
+ def convert_blob_columns(df: pd.DataFrame) -> pd.DataFrame:
125
+ """BLOB์ด ์‹ค์ œ๋กœ ์กด์žฌํ•˜๋Š” ์ปฌ๋Ÿผ๋งŒ ๋ณ€ํ™˜ (๋ถˆํ•„์š”ํ•œ ์ˆœํšŒ ์ œ๊ฑฐ)."""
126
+ for col in df.columns:
127
+ # ์ƒ˜ํ”Œ ์ฒซ ํ–‰๋งŒ ํ™•์ธํ•ด์„œ bytes ์—ฌ๋ถ€ ํŒ๋‹จ
128
+ sample = df[col].dropna().head(1)
129
+ if not sample.empty and isinstance(sample.iloc[0], (bytes, bytearray)):
130
+ df[col] = df[col].apply(blob_to_base64_html)
131
+ return df
132
+
133
+
134
+ _sort_key_cache: dict[str, list] = {}
135
+
136
  def natural_sort_key(s):
137
+ """๊ฒฐ๊ณผ๋ฅผ ์บ์‹ฑํ•ด ๋™์ผ ๊ฐ’ ์žฌ๊ณ„์‚ฐ ๋ฐฉ์ง€."""
138
  if s is None:
139
  return []
140
+ s = str(s)
141
+ if s not in _sort_key_cache:
142
+ _sort_key_cache[s] = [
143
+ int(t) if t.isdigit() else t.lower()
144
+ for t in re.split(r'(\d+)', s)
145
+ ]
146
+ return _sort_key_cache[s]
147
+
148
+
149
+ def natural_sort_key_list(series: pd.Series):
150
+ return series.map(natural_sort_key)
151
+
152
+
153
+ # --------------------------
154
+ # diff (์ตœ์ ํ™”)
155
+ # --------------------------
156
+ def highlight_diff(base: str, comp: str):
157
+ """SequenceMatcher ๊ธฐ๋ฐ˜์œผ๋กœ ๊ต์ฒด โ€” ndiff๋ณด๋‹ค ๋น ๋ฆ„."""
158
+ b_words = base.split()
159
+ c_words = comp.split()
160
+
161
+ sm = difflib.SequenceMatcher(None, b_words, c_words, autojunk=False)
162
+ b_res, c_res = [], []
163
+
164
+ for tag, i1, i2, j1, j2 in sm.get_opcodes():
165
+ if tag == 'equal':
166
+ b_res.extend(b_words[i1:i2])
167
+ c_res.extend(c_words[j1:j2])
168
+ elif tag == 'replace':
169
+ for w in b_words[i1:i2]:
170
+ b_res.append(f"<span style='color:#ff4d4f;font-weight:600'>{w}</span>")
171
+ for w in c_words[j1:j2]:
172
+ c_res.append(f"<span style='color:#ff4d4f;font-weight:600'>{w}</span>")
173
+ elif tag == 'delete':
174
+ for w in b_words[i1:i2]:
175
+ b_res.append(f"<span style='color:#ff4d4f;font-weight:600'>{w}</span>")
176
+ elif tag == 'insert':
177
+ for w in c_words[j1:j2]:
178
+ c_res.append(f"<span style='color:#2ecc71;font-weight:600'>{w}</span>")
179
+
180
+ return " ".join(b_res), " ".join(c_res)
181
+
182
+
183
+ def apply_diff_row(row, base_col, comp_col):
184
+ base_val = str(row.get(base_col, "") or "")
185
+ comp_val = str(row.get(comp_col, "") or "")
186
+
187
+ if "<img" in base_val or "<img" in comp_val:
188
+ return pd.Series([base_val, comp_val])
189
+ if base_val == comp_val:
190
+ return pd.Series([base_val, comp_val])
191
+ if base_val and comp_val:
192
+ b, c = highlight_diff(base_val, comp_val)
193
+ return pd.Series([b, c])
194
+ return pd.Series([base_val, comp_val])
195
+
196
 
197
+ # --------------------------
198
+ # ๋ ˆ์ง€์ŠคํŠธ๋ฆฌ
199
+ # --------------------------
200
  def refresh_registry_data():
201
  global db_registry
202
  db_registry = []
 
214
 
215
  return sorted(list(set([d["standard"] for d in db_registry])))
216
 
217
+
218
  # --------------------------
219
  # ๋“œ๋กญ๋‹ค์šด
220
  # --------------------------
 
224
  def update_version_dd(standard):
225
  if not standard:
226
  return gr.Dropdown(choices=[])
227
+ versions = sorted(set(d["version"] for d in db_registry if d["standard"] == standard))
 
 
228
  return gr.Dropdown(choices=versions)
229
 
230
  def update_category_dd(standard, version):
 
235
 
236
  try:
237
  target = next(d for d in db_registry if d["standard"] == standard and d["version"] == version)
238
+ conn = get_connection(target["path"])
 
 
 
239
 
240
  tables = pd.read_sql("SELECT name FROM sqlite_master WHERE type='table';", conn)['name'].tolist()
241
  main_t = f"{standard}_{version}"
 
245
  cols = pd.read_sql(f"PRAGMA table_info([{main_t}])", conn)['name'].tolist()
246
  lower = [c.lower() for c in cols]
247
 
 
248
  if 'chapter' in lower and 'category' in lower:
249
  ch = cols[lower.index('chapter')]
250
  ca = cols[lower.index('category')]
 
251
  df = pd.read_sql(f"SELECT DISTINCT [{ch}], [{ca}] FROM [{main_t}]", conn)
 
252
  for _, r in df.iterrows():
253
  choices.append(f"{str(r[ch]).strip()}.{str(r[ca]).strip()}")
254
 
 
255
  pattern = re.compile(f"^{standard}[_\\s-]*{version}[_\\s-]*", re.IGNORECASE)
 
256
  for t in tables:
257
  if t == main_t:
258
  continue
 
 
259
  short_name = pattern.sub("", t).strip(" _")
260
  choices.append(short_name)
261
 
262
+ except Exception:
 
 
 
263
  traceback.print_exc()
264
 
265
  return gr.Dropdown(choices=choices)
266
 
267
+
268
  # --------------------------
269
  # ์ดˆ๊ธฐํ™”
270
  # --------------------------
 
274
  def reset_comp():
275
  return None, None, None
276
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
277
 
278
  # --------------------------
279
+ # ๋ฐ์ดํ„ฐ ์กฐํšŒ (์บ์‹ฑ ์ ์šฉ)
280
  # --------------------------
281
  def display_data(standard, version, selection):
282
  if not all([standard, version, selection]):
283
  return pd.DataFrame({"Info": ["์„ ํƒ ํ•„์š”"]})
284
 
285
+ # ์บ์‹œ ํ™•์ธ
286
+ ck = _cache_key(standard, version, selection)
287
+ cached = get_cached_df(ck)
288
+ if cached is not None:
289
+ return cached.copy()
290
+
291
  try:
292
  target = next(d for d in db_registry if d["standard"] == standard and d["version"] == version)
293
+ conn = get_connection(target["path"])
 
 
 
294
 
295
  tables = pd.read_sql(
296
+ "SELECT name FROM sqlite_master WHERE type='table';", conn
 
297
  )['name'].tolist()
298
 
299
  main_t = f"{standard}_{version}"
300
  if main_t not in tables:
301
  main_t = tables[0]
302
 
303
+ # ํ…Œ์ด๋ธ” reverse lookup
 
 
304
  full_table_name = None
305
  pattern = re.compile(f"^{standard}[_\\s-]*{version}[_\\s-]*", re.IGNORECASE)
 
306
  for t in tables:
307
  if t == selection:
308
  full_table_name = t
 
312
  full_table_name = t
313
  break
314
 
315
+ # TableA/B ์„ ํƒ
 
 
316
  if full_table_name:
317
  cursor = conn.cursor()
318
  cursor.execute(f"SELECT * FROM [{full_table_name}]")
 
319
  rows = cursor.fetchall()
320
  cols = [desc[0] for desc in cursor.description]
321
 
322
  df = pd.DataFrame(rows, columns=cols)
 
323
  df.columns = [c.strip() for c in df.columns]
324
  df = df.drop(columns=[c for c in df.columns if c.lower() == "version"], errors="ignore")
325
  df.columns = [c.replace("_", " ").title() for c in df.columns]
326
+ df = convert_blob_columns(df)
327
 
328
+ set_cached_df(ck, df)
329
+ return df.copy()
330
 
331
+ # ๋ฉ”์ธ ํ…Œ์ด๋ธ” ๊ตฌ์กฐ ํŒŒ์•…
 
 
 
 
 
332
  cursor = conn.cursor()
333
  cursor.execute(f"PRAGMA table_info([{main_t}])")
334
  cols_info = cursor.fetchall()
 
342
  elif lc == "category":
343
  real_cat = c
344
 
 
 
 
345
  if selection == "ALL":
 
346
  cursor.execute(f"SELECT * FROM [{main_t}]")
347
 
 
 
 
348
  elif "." in selection and real_ch and real_cat:
349
  ch, ca = selection.split(".", 1)
 
350
  query = f"""
351
  SELECT * FROM [{main_t}]
352
  WHERE REPLACE(TRIM(CAST([{real_ch}] AS TEXT)), ' ', '') = ?
353
  AND REPLACE(TRIM(CAST([{real_cat}] AS TEXT)), ' ', '') = ?
354
  """
 
355
  cursor.execute(query, (ch.replace(" ", ""), ca.replace(" ", "")))
356
 
 
 
 
357
  else:
358
  cursor.execute(f"SELECT * FROM [{main_t}]")
359
 
360
  rows = cursor.fetchall()
361
  df = pd.DataFrame(rows, columns=cols)
 
 
 
 
362
  df.columns = [c.lower().strip() for c in df.columns]
363
 
364
  if 'section' in df.columns and 'description' in df.columns:
365
  df = df[['section', 'description']]
366
 
367
+ df = convert_blob_columns(df)
 
 
368
 
369
+ set_cached_df(ck, df)
370
+ return df.copy()
371
 
372
  except Exception as e:
 
373
  traceback.print_exc()
374
  return pd.DataFrame({"Error": [str(e)]})
375
 
376
+
377
  # --------------------------
378
+ # ํ†ตํ•ฉ ์กฐํšŒ
379
  # --------------------------
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
380
  def unified_search(bs, bv, bc, cs, cv, cc):
381
+ # ๋‹จ์ผ ์กฐํšŒ
382
  if bs and bv and bc and not (cs and cv and cc):
383
  df = display_data(bs, bv, bc)
384
  return df.rename(columns={"section": "Section", "description": "Description"})
385
 
386
+ # ๋น„๊ต ์กฐํšŒ
387
  if bs and bv and bc and cs and cv and cc:
388
  df_base = display_data(bs, bv, bc)
389
  df_comp = display_data(cs, cv, cc)
 
391
  if 'section' not in df_base.columns or 'section' not in df_comp.columns:
392
  return pd.DataFrame({"Error": ["section ์—†์Œ"]})
393
 
394
+ df_base = df_base.rename(columns={"section": f"Section_{bv}", "description": f"Description_{bv}"})
395
+ df_comp = df_comp.rename(columns={"section": f"Section_{cv}", "description": f"Description_{cv}"})
 
 
 
 
 
 
 
396
 
397
  base_sec = f"Section_{bv}"
398
  comp_sec = f"Section_{cv}"
399
  base_col = f"Description_{bv}"
400
  comp_col = f"Description_{cv}"
401
 
402
+ merged = pd.merge(df_base, df_comp, left_on=base_sec, right_on=comp_sec, how="outer")
 
 
 
 
 
 
 
 
403
  merged = merged.fillna("")
404
 
 
405
  merged[[base_col, comp_col]] = merged.apply(
406
+ lambda row: apply_diff_row(row, base_col, comp_col), axis=1
 
 
 
 
 
 
407
  )
408
 
409
+ merged["__sort_key"] = merged[base_sec].where(merged[base_sec] != "", merged[comp_sec])
410
  merged = merged.sort_values(
411
  by="__sort_key",
412
+ key=natural_sort_key_list
413
  ).drop(columns="__sort_key")
414
 
415
  return merged
416
 
417
  return pd.DataFrame({"Info": ["์„ ํƒ ํ•„์š”"]})
418
 
419
+
420
  # --------------------------
421
  # UI
422
  # --------------------------
 
460
  base_reset_btn.click(reset_base, None, [base_standard, base_version, base_category])
461
  comp_reset_btn.click(reset_comp, None, [comp_standard, comp_version, comp_category])
462
 
463
+
464
  # --------------------------
465
  # CSS
466
  # --------------------------
 
469
  table-layout: fixed !important;
470
  width: 100% !important;
471
  }
 
 
 
 
472
  table th:nth-last-child(2):first-child, table td:nth-last-child(2):first-child { width: 15% !important; }
473
  table th:nth-last-child(1), table td:nth-last-child(1) { width: 85% !important; }
 
 
 
 
474
  table th:nth-child(1):nth-last-child(4), table td:nth-child(1):nth-last-child(4) { width: 7% !important; }
475
  table th:nth-child(2):nth-last-child(3), table td:nth-child(2):nth-last-child(3) { width: 43% !important; }
476
  table th:nth-child(3):nth-last-child(2), table td:nth-child(3):nth-last-child(2) { width: 7% !important; }
477
  table th:nth-child(4):nth-last-child(1), table td:nth-child(4):nth-last-child(1) { width: 43% !important; }
 
 
 
 
478
  table th:nth-child(1):nth-last-child(5), table td:nth-child(1):nth-last-child(5) { width: 15% !important; }
479
  table th:nth-child(2):nth-last-child(4), table td:nth-child(2):nth-last-child(4) { width: 10% !important; }
480
  table th:nth-child(3):nth-last-child(3), table td:nth-child(3):nth-last-child(3) { width: 30% !important; }
481
  table th:nth-child(4):nth-last-child(2), table td:nth-child(4):nth-last-child(2) { width: 10% !important; }
482
  table th:nth-child(5):nth-last-child(1), table td:nth-child(5):nth-last-child(1) { width: 35% !important; }
 
 
 
 
483
  table th:nth-child(1):nth-last-child(6), table td:nth-child(1):nth-last-child(6) { width: 8% !important; }
484
  table th:nth-child(2):nth-last-child(5), table td:nth-child(2):nth-last-child(5) { width: 25% !important; }
485
  table th:nth-child(3):nth-last-child(4), table td:nth-child(3):nth-last-child(4) { width: 8% !important; }
486
  table th:nth-child(4):nth-last-child(3), table td:nth-child(4):nth-last-child(3) { width: 25% !important; }
487
  table th:nth-child(5):nth-last-child(2), table td:nth-child(5):nth-last-child(2) { width: 8% !important; }
488
  table th:nth-child(6):nth-last-child(1), table td:nth-child(6):nth-last-child(1) { width: 26% !important; }
489
+ thead th { position: sticky; top: 0; background: white; z-index: 10; }
490
+ .dataframe { max-height: none !important; overflow-y: visible !important; }
491
+ .dataframe > div { max-height: none !important; overflow: visible !important; }
492
+ td { white-space: pre-wrap !important; word-break: break-word !important; line-height: 1.6; padding: 10px; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
493
  """
494
 
495
  if __name__ == "__main__":