QIDNLF commited on
Commit
3855936
·
verified ·
1 Parent(s): 4d27c35

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +831 -89
app.py CHANGED
@@ -6,745 +6,1487 @@ import re
6
  import base64
7
  import difflib
8
 
 
9
  # ==========================================
10
  # 1. Environment Setup & Data Helpers
11
  # ==========================================
 
12
  UPLOAD_DIR = "uploaded_dbs"
 
13
  if not os.path.exists(UPLOAD_DIR):
 
14
  os.makedirs(UPLOAD_DIR)
15
 
 
 
16
  db_registry = []
17
 
 
 
18
  def convert_blob_to_html_img(blob_data):
 
19
  if blob_data is None or pd.isna(blob_data):
 
20
  return ""
 
21
  try:
 
22
  if isinstance(blob_data, (bytes, bytearray)):
 
23
  encoded = base64.b64encode(blob_data).decode('utf-8')
 
24
  return f'''
 
25
  <img src="data:image/png;base64,{encoded}"
 
26
  style="width: 40%;
 
27
  max-height: 300px;
 
28
  object-fit: contain;
 
29
  display: block;
 
30
  margin: 10px 0;">
 
31
  '''
 
32
  return str(blob_data)
 
33
  except Exception:
 
34
  return str(blob_data)
35
 
 
 
36
  def decode_sqlite_text(x):
 
37
  try:
 
38
  return x.decode('utf-8')
 
39
  except UnicodeDecodeError:
 
40
  return x
41
 
 
 
42
  def fetch_available_standards():
 
43
  global db_registry
 
44
  db_registry = []
 
45
  for file_name in os.listdir(UPLOAD_DIR):
 
46
  if file_name.endswith(".db"):
 
47
  name = file_name.replace(".db", "")
 
48
  parts = name.split("_")
 
49
  if len(parts) >= 2:
 
50
  db_registry.append({
 
51
  "path": os.path.join(UPLOAD_DIR, file_name),
 
52
  "standard": parts[0],
 
53
  "version": parts[1]
 
54
  })
 
55
  return sorted(list(set([d["standard"] for d in db_registry])))
56
 
 
 
57
  def fetch_database_records(std, ver, cat, table_type):
 
58
  try:
 
59
  db_path = os.path.join(UPLOAD_DIR, f"{std}_{ver}.db")
 
60
  conn = sqlite3.connect(db_path)
 
61
  conn.text_factory = decode_sqlite_text
 
62
 
 
63
  tables = pd.read_sql("SELECT name FROM sqlite_master WHERE type='table';", conn)['name'].tolist()
 
64
  valid_tables = [t for t in tables if t.lower() not in ['mapping_registry', 'mapping_table', 'table_config', 'sqlite_sequence']]
 
65
 
 
66
  main_table = None
 
67
  if table_type and table_type.upper() != "MAIN":
 
68
  expected_name = f"{std}_{ver}_{table_type}"
 
69
  for t in valid_tables:
 
70
  if t.lower() == expected_name.lower() or t.lower() == table_type.lower():
 
71
  main_table = t
 
72
  break
 
73
 
 
74
  if not main_table:
 
75
  main_table = f"{std}_{ver}"
 
76
  if main_table not in valid_tables:
 
77
  main_table = valid_tables[0] if valid_tables else None
 
78
 
 
79
  if not main_table:
 
80
  conn.close()
 
81
  return pd.DataFrame({"Error": ["데이터 테이블을 찾을 수 없습니다."]}), []
 
82
 
 
83
  cols = pd.read_sql(f"PRAGMA table_info([{main_table}])", conn)['name'].tolist()
 
84
  lower_cols = [c.lower() for c in cols]
 
85
 
 
86
  conn_map = sqlite3.connect(os.path.join(UPLOAD_DIR, "mapping.db"))
 
87
  config_df = pd.read_sql("SELECT * FROM Table_Config WHERE TRIM(Table_Type)=?", conn_map, params=[table_type.strip()])
 
88
  conn_map.close()
 
89
 
 
90
  if config_df.empty:
 
91
  return pd.DataFrame({"Error": [f"Table_Config에서 '{table_type}' 설정을 찾을 수 없습니다."]}), []
 
92
 
 
93
  matched_config = None
 
94
  for _, row in config_df.iterrows():
 
95
  anchors_test = [x.strip().lower() for x in str(row['Anchor_Column']).split(',')]
 
96
  if any(a in lower_cols for a in anchors_test):
 
97
  matched_config = row
 
98
  break
 
99
 
 
100
  if matched_config is None:
 
101
  matched_config = config_df.iloc[0]
 
102
 
 
103
  anchors = [x.strip() for x in matched_config['Anchor_Column'].split(',')]
 
104
  displays = [x.strip() for x in matched_config['Display_Columns'].split(',')] if pd.notna(matched_config['Display_Columns']) else anchors
 
105
 
 
106
  query = f"SELECT * FROM [{main_table}]"
 
107
  conditions = []
 
108
 
 
109
  if cat and cat != "ALL":
 
110
  if "." in cat and 'chapter' in lower_cols and 'category' in lower_cols:
 
111
  ch, ca = cat.split(".", 1)
 
112
  ch_col = cols[lower_cols.index('chapter')]
 
113
  ca_col = cols[lower_cols.index('category')]
 
114
  conditions.append(f"[{ch_col}] = '{ch}' AND [{ca_col}] = '{ca}'")
 
115
  elif 'category' in lower_cols:
 
116
  ca_col = cols[lower_cols.index('category')]
 
117
  conditions.append(f"[{ca_col}] = '{cat}'")
 
118
 
 
119
  if conditions:
 
120
  query += " WHERE " + " AND ".join(conditions)
 
121
 
 
122
  df = pd.read_sql(query, conn)
 
123
  conn.close()
 
124
 
 
125
  real_anchors = [c for c in df.columns if any(a.lower() == c.lower() for a in anchors)]
 
126
 
 
127
  final_cols = []
 
128
  for d in displays:
 
129
  for c in df.columns:
 
130
  if d.lower() == c.lower():
 
131
  if c not in final_cols:
 
132
  final_cols.append(c)
 
133
  break
 
134
 
 
135
  for ra in real_anchors:
 
136
  if ra not in final_cols:
 
137
  final_cols.append(ra)
 
138
 
 
139
  if not final_cols:
 
140
  return df, real_anchors
 
141
 
 
142
  for c in final_cols:
 
143
  df[c] = df[c].apply(convert_blob_to_html_img)
 
144
 
 
145
  return df[final_cols], real_anchors
 
146
 
 
147
  except Exception as e:
 
148
  import traceback
 
149
  traceback.print_exc()
 
150
  return pd.DataFrame({"Error": [f"데이터 로드 오류: {str(e)}"]}), []
151
 
 
 
152
  def generate_html_diff(text1, text2):
 
153
  try:
 
154
  s1, s2 = str(text1), str(text2)
 
155
 
 
156
  if len(s1) > 1000 or len(s2) > 1000:
 
157
  return s1, s2
 
158
 
 
159
  words1, words2 = s1.split(), s2.split()
 
160
  if not words1 or not words2:
 
161
  return s1, s2
 
162
 
 
163
  common_words = set(words1) & set(words2)
 
164
  if len(common_words) / min(len(words1), len(words2)) < 0.05:
 
165
  return s1, s2
166
 
 
 
167
  matcher = difflib.SequenceMatcher(None, words1, words2)
 
168
  res1, res2 = [], []
 
169
 
 
170
  for tag, i1, i2, j1, j2 in matcher.get_opcodes():
 
171
  if tag == 'replace':
 
172
  res1.append(f"<span style='color:#ff4d4f; font-weight:bold;'>{' '.join(words1[i1:i2])}</span>")
 
173
  res2.append(f"<span style='color:#2ecc71; font-weight:bold;'>{' '.join(words2[j1:j2])}</span>")
 
174
  elif tag == 'delete':
 
175
  res1.append(f"<span style='color:#ff4d4f; font-weight:bold;'>{' '.join(words1[i1:i2])}</span>")
 
176
  elif tag == 'insert':
 
177
  res2.append(f"<span style='color:#2ecc71; font-weight:bold;'>{' '.join(words2[j1:j2])}</span>")
 
178
  elif tag == 'equal':
 
179
  res1.append(' '.join(words1[i1:i2]))
 
180
  res2.append(' '.join(words2[j1:j2]))
 
181
 
 
182
  return " ".join(res1), " ".join(res2)
 
183
  except Exception:
 
184
  return text1, text2
185
 
 
 
186
  # ==========================================
 
187
  # 2. UI Component Handlers
 
188
  # ==========================================
 
189
  def load_initial_standards():
 
190
  return gr.Dropdown(choices=fetch_available_standards())
191
 
 
 
192
  def update_version_dropdown(standard):
 
193
  if not standard:
 
194
  return gr.Dropdown(choices=[])
 
195
  versions = []
 
196
  for file_name in os.listdir(UPLOAD_DIR):
 
197
  if file_name.startswith(standard + "_") and file_name.endswith(".db"):
 
198
  versions.append(file_name.replace(standard + "_", "").replace(".db", ""))
 
199
  return gr.Dropdown(choices=sorted(list(set(versions))))
200
 
 
 
201
  def update_base_category_dropdown(standard, version):
 
202
  if not standard or not version:
 
203
  return gr.update(choices=[], value=None, interactive=False), gr.update(value="")
204
 
 
 
205
  choices = ["ALL"]
 
206
  status_value = ""
 
207
  db_path = os.path.join(UPLOAD_DIR, f"{standard}_{version}.db")
 
208
 
 
209
  if not os.path.exists(db_path):
 
210
  return gr.update(choices=choices), gr.update(value="")
211
 
 
 
212
  try:
 
213
  conn = sqlite3.connect(db_path)
 
214
  tables = pd.read_sql("SELECT name FROM sqlite_master WHERE type='table';", conn)['name'].tolist()
 
215
  valid_tables = [t for t in tables if t.lower() not in ['mapping_registry', 'mapping_table', 'table_config', 'sqlite_sequence']]
 
216
  main_table = f"{standard}_{version}"
 
217
  if main_table not in valid_tables:
 
218
  main_table = valid_tables[0] if valid_tables else None
219
 
 
 
220
  if main_table:
 
221
  try:
 
222
  cols_check = pd.read_sql(f"PRAGMA table_info([{main_table}])", conn)['name'].tolist()
 
223
  if "Status" in cols_check or "status" in cols_check:
 
224
  status_df = pd.read_sql(f"SELECT Status FROM [{main_table}] WHERE Status IS NOT NULL AND Status != '' LIMIT 1", conn)
 
225
  if not status_df.empty:
 
226
  status_value = str(status_df.iloc[0]['Status'])
 
227
  except Exception:
 
228
  pass
229
 
 
 
230
  cols = pd.read_sql(f"PRAGMA table_info([{main_table}])", conn)['name'].tolist()
 
231
  lower_cols = [c.lower() for c in cols]
 
232
  if 'chapter' in lower_cols and 'category' in lower_cols:
 
233
  ch_col = cols[lower_cols.index('chapter')]
 
234
  ca_col = cols[lower_cols.index('category')]
 
235
 
 
236
  df = pd.read_sql(f"SELECT DISTINCT [{ch_col}], [{ca_col}] FROM [{main_table}]", conn)
 
237
  for _, row in df.iterrows():
 
238
  ch = str(row[ch_col]).strip()
 
239
  ca = str(row[ca_col]).strip()
 
240
  if ch and ca and ch.lower() not in ['none', 'nan'] and ca.lower() not in ['none', 'nan']:
 
241
  choices.append(f"{ch}.{ca}")
242
 
 
 
243
  pattern = re.compile(f"^{standard}[_\\s-]*{version}[_\\s-]*", re.IGNORECASE)
 
244
  for t in valid_tables:
 
245
  if t == main_table: continue
 
246
  short_name = pattern.sub("", t).strip(" _")
 
247
  if short_name and short_name not in choices:
 
248
  choices.append(short_name)
 
249
  elif t not in choices:
 
250
  choices.append(t)
251
 
 
 
252
  conn.close()
 
253
  except Exception:
 
254
  pass
 
255
 
 
256
  return gr.update(choices=choices, value=None, interactive=True), gr.update(value=status_value)
257
 
 
 
258
  def update_comp_standard_dropdown(base_std, base_ver):
 
259
  if not base_std or not base_ver:
 
260
  return gr.Dropdown(choices=[], value=None, interactive=False)
261
 
 
 
262
  try:
 
263
  conn = sqlite3.connect(os.path.join(UPLOAD_DIR, "mapping.db"))
 
264
  query = "SELECT DISTINCT TRIM(Comp_std) AS Comp_std FROM Mapping_registry WHERE TRIM(Base_std)=TRIM(?) AND TRIM(Base_ver)=TRIM(?)"
 
265
  df = pd.read_sql(query, conn, params=[base_std, base_ver])
 
266
  conn.close()
267
 
 
 
268
  mapped_stds = sorted(df['Comp_std'].dropna().unique().tolist()) if not df.empty else []
 
269
  return gr.update(choices=mapped_stds, value=None, interactive=bool(mapped_stds))
 
270
  except Exception:
 
271
  return gr.update(choices=[], value=None, interactive=False)
272
 
 
 
273
  def update_comp_version_dropdown(base_std, base_ver, comp_std):
 
274
  if not all([base_std, base_ver, comp_std]):
 
275
  return gr.update(choices=[], value=None, interactive=False)
276
 
 
 
277
  try:
 
278
  conn = sqlite3.connect(os.path.join(UPLOAD_DIR, "mapping.db"))
 
279
  query = "SELECT DISTINCT TRIM(Comp_ver) AS Comp_ver FROM Mapping_registry WHERE TRIM(Base_std)=TRIM(?) AND TRIM(Base_ver)=TRIM(?) AND TRIM(Comp_std)=TRIM(?)"
 
280
  df = pd.read_sql(query, conn, params=[base_std, base_ver, comp_std])
 
281
  conn.close()
282
 
 
 
283
  mapped_vers = sorted(df['Comp_ver'].dropna().unique().tolist()) if not df.empty else []
 
284
  return gr.update(choices=mapped_vers, value=None, interactive=bool(mapped_vers))
 
285
  except Exception:
 
286
  return gr.update(choices=[], value=None, interactive=False)
287
 
 
 
288
  def update_comp_category_dropdown(base_std, base_ver, base_cat, comp_std, comp_ver):
 
289
  if not all([base_std, base_ver, base_cat, comp_std, comp_ver]):
 
290
  return gr.update(choices=[], value=None, interactive=False), gr.update(value="")
291
 
 
 
292
  base_type = "Main" if not base_cat or base_cat == "ALL" or "." in base_cat else base_cat
 
293
  status_value = ""
 
294
  comp_db_path = os.path.join(UPLOAD_DIR, f"{comp_std}_{comp_ver}.db")
 
295
  c_main_table = None
296
 
 
 
297
  try:
 
298
  if os.path.exists(comp_db_path):
 
299
  c_conn = sqlite3.connect(comp_db_path)
 
300
  c_tables = pd.read_sql("SELECT name FROM sqlite_master WHERE type='table';", c_conn)['name'].tolist()
 
301
  c_valid_tables = [t for t in c_tables if t.lower() not in ['mapping_registry', 'mapping_table', 'table_config', 'sqlite_sequence']]
 
302
  c_main_table = f"{comp_std}_{comp_ver}"
 
303
  if c_main_table not in c_valid_tables:
 
304
  c_main_table = c_valid_tables[0] if c_valid_tables else None
 
305
 
 
306
  if c_main_table:
 
307
  try:
 
308
  cols_check = pd.read_sql(f"PRAGMA table_info([{c_main_table}])", c_conn)['name'].tolist()
 
309
  if "Status" in cols_check or "status" in cols_check:
 
310
  status_df = pd.read_sql(f"SELECT Status FROM [{c_main_table}] WHERE Status IS NOT NULL AND Status != '' LIMIT 1", c_conn)
 
311
  if not status_df.empty:
 
312
  status_value = str(status_df.iloc[0]['Status'])
 
313
  except Exception:
 
314
  pass
 
315
  c_conn.close()
316
 
 
 
317
  conn = sqlite3.connect(os.path.join(UPLOAD_DIR, "mapping.db"))
 
318
  query = """
 
319
  SELECT DISTINCT TRIM(Comp_Type) AS Comp_Type
 
320
  FROM Mapping_registry
 
321
  WHERE TRIM(Base_std)=TRIM(?) AND TRIM(Base_ver)=TRIM(?) AND TRIM(Base_Type)=TRIM(?) AND TRIM(Comp_std)=TRIM(?) AND TRIM(Comp_ver)=TRIM(?)
 
322
  """
 
323
  df = pd.read_sql(query, conn, params=[base_std, base_ver, base_type, comp_std, comp_ver])
 
324
  conn.close()
325
 
 
 
326
  allowed_types = df['Comp_Type'].dropna().tolist()
 
327
  if not allowed_types:
 
328
  return gr.update(choices=[], value=None), gr.update(value=status_value)
329
 
 
 
330
  final_choices = []
 
331
  if "Main" in allowed_types:
 
332
  final_choices.append("ALL")
 
333
  if os.path.exists(comp_db_path) and c_main_table:
 
334
  c_conn = sqlite3.connect(comp_db_path)
 
335
  cols = pd.read_sql(f"PRAGMA table_info([{c_main_table}])", c_conn)['name'].tolist()
 
336
  lower_cols = [c.lower() for c in cols]
 
337
  if 'chapter' in lower_cols and 'category' in lower_cols:
 
338
  ch_col = cols[lower_cols.index('chapter')]
 
339
  ca_col = cols[lower_cols.index('category')]
 
340
  c_df = pd.read_sql(f"SELECT DISTINCT [{ch_col}], [{ca_col}] FROM [{c_main_table}]", c_conn)
 
341
  for _, row in c_df.iterrows():
 
342
  ch = str(row[ch_col]).strip()
 
343
  ca = str(row[ca_col]).strip()
 
344
  if ch and ca and ch.lower() not in ['none', 'nan'] and ca.lower() not in ['none', 'nan']:
 
345
  final_choices.append(f"{ch}.{ca}")
 
346
  c_conn.close()
 
347
 
 
348
  for t in allowed_types:
 
349
  if t != "Main": final_choices.append(t)
350
 
 
 
351
  return gr.update(choices=final_choices, value=final_choices[0] if final_choices else None, interactive=True), gr.update(value=status_value)
 
352
 
 
353
  except Exception:
 
354
  return gr.update(choices=[], value=None), gr.update(value="")
355
 
 
 
356
  def reset_base_selections():
 
357
  return gr.update(value=None), gr.update(choices=[], value=None), gr.update(choices=[], value=None), gr.update(value="")
358
 
 
 
359
  def reset_comp_selections():
 
360
  return gr.update(value=None), gr.update(choices=[], value=None), gr.update(choices=[], value=None), gr.update(value="")
361
 
 
 
362
  # ==========================================
 
363
  # 3. Core Search Logic
 
364
  # ==========================================
 
365
  def execute_unified_search(base_std, base_ver, base_cat, comp_std, comp_ver, comp_cat, mapped_only, diff_only):
 
366
  try:
 
367
  def get_type_by_cat(cat):
 
368
  if not cat or cat == "ALL": return "Main"
 
369
  if "." in cat: return "Main"
 
370
  return cat
371
 
 
 
372
  type_b = get_type_by_cat(base_cat)
 
373
  type_c = get_type_by_cat(comp_cat)
 
374
 
 
375
  def apply_visual_merge(df, cols):
 
376
  if not df.empty and len(cols) > 1:
 
377
  is_dup = pd.Series([True] * len(df), index=df.index)
 
378
  for col in cols:
 
379
  if col in df.columns:
 
380
  curr = df[col].astype(str).str.strip()
 
381
  match = (curr == curr.shift(1)) & (~curr.isin(["", "nan", "None", "&nbsp;"]))
 
382
  is_dup = is_dup & match
 
383
  df.loc[is_dup, col] = "&nbsp;"
 
384
  return df
385
 
 
 
386
  def combine_code_desc(df):
 
387
  cols = list(df.columns)
 
388
  new_cols = []
 
389
  processed = set()
 
390
  for col in cols:
 
391
  if col in processed: continue
 
392
  if "_Code" in col:
 
393
  desc_col = col.replace("_Code", "_Description")
 
394
  if desc_col in cols:
 
395
  new_col_name = col.replace("_Code", "")
 
396
  def combine_cells(row):
 
397
  c, d = str(row[col]).strip(), str(row[desc_col]).strip()
 
398
  if c in ["nan", "None", "", "&nbsp;"]: return d
 
399
  if d in ["nan", "None", "", "&nbsp;"]: return f"<span style='font-weight:bold; color:#1a73e8;'>{c}</span>"
 
400
  return f"<span style='font-weight:bold; color:#1a73e8; display:block; margin-bottom:4px;'>{c}</span>{d}"
 
401
  df[new_col_name] = df.apply(combine_cells, axis=1)
 
402
  new_cols.append(new_col_name)
 
403
  processed.update([col, desc_col])
 
404
  else: new_cols.append(col)
 
405
  elif "_Description" in col:
 
406
  if col.replace("_Description", "_Code") not in cols: new_cols.append(col)
 
407
  else: new_cols.append(col)
 
408
  return df[new_cols]
409
 
 
 
410
  if base_std and base_ver and base_cat and (not comp_std or not comp_ver or not comp_cat):
 
411
  df, _ = fetch_database_records(base_std, base_ver, base_cat, type_b)
 
412
  if "Error" in df.columns: return df
413
-
414
- # 💡 [요청 반영] 단일 조회 시 화면 하단 표에 Status 열이 나오지 않도록 처리합니다.
415
- status_cols = [c for c in df.columns if c.lower() == 'status']
416
- if status_cols:
417
- df = df.drop(columns=status_cols)
418
-
419
  return apply_visual_merge(df, df.columns)
420
 
 
 
421
  if all([base_std, base_ver, base_cat, comp_std, comp_ver, comp_cat]):
 
422
  df_base, real_anchors_b = fetch_database_records(base_std, base_ver, base_cat, type_b)
 
423
  df_comp, real_anchors_c = fetch_database_records(comp_std, comp_ver, comp_cat, type_c)
424
 
 
 
425
  if "Error" in df_base.columns: return df_base
 
426
  if "Error" in df_comp.columns: return df_comp
427
 
 
 
428
  for ra in real_anchors_b:
 
429
  if ra not in df_base.columns:
 
430
  return pd.DataFrame({"Error": [f"기준 열(Anchor) '{ra}'이(가) 기준 데이터에 존재하지 않습니다. Table_Config를 확인하세요."]})
 
431
  for ra in real_anchors_c:
 
432
  if ra not in df_comp.columns:
 
433
  return pd.DataFrame({"Error": [f"비교 열(Anchor) '{ra}'이(가) 비교 데이터에 존재하지 않습니다. Table_Config를 확인하세요."]})
434
 
 
 
435
  def clean_key_val(v):
 
436
  s = str(v).strip()
 
437
  if s.endswith('.0') and s[:-2].isdigit():
 
438
  s = s[:-2]
 
439
  return s.replace(" ", "")
440
 
441
- df_base['merge_key'] = df_base[real_anchors_b].apply(lambda row: '-'.join([clean_key_val(x) for x in row]), axis=1)
442
- df_comp['merge_key'] = df_comp[real_anchors_c].apply(lambda row: '-'.join([clean_key_val(x) for x in row]), axis=1)
443
 
444
- conn_map = sqlite3.connect(os.path.join(UPLOAD_DIR, "mapping.db"))
445
- registry_query = """
446
- SELECT Target_Table FROM Mapping_registry
447
- WHERE TRIM(Base_std)=? AND TRIM(Base_ver)=? AND TRIM(Comp_std)=? AND TRIM(Comp_ver)=?
448
- LIMIT 1
449
- """
450
- reg_df = pd.read_sql(registry_query, conn_map, params=[base_std.strip(), base_ver.strip(), comp_std.strip(), comp_ver.strip()])
451
-
452
- target_table_name = "Mapping_table"
453
- if not reg_df.empty and pd.notna(reg_df.iloc[0]['Target_Table']):
454
- val = str(reg_df.iloc[0]['Target_Table']).strip()
455
- if val and val.lower() not in ["none", "nan"]:
456
- target_table_name = val
457
 
458
- try:
459
- q_fw = f"SELECT * FROM [{target_table_name}] WHERE TRIM(Base_std)=? AND TRIM(Base_ver)=? AND TRIM(Comp_std)=? AND TRIM(Comp_ver)=?"
460
- df_fw = pd.read_sql(q_fw, conn_map, params=[base_std.strip(), base_ver.strip(), comp_std.strip(), comp_ver.strip()])
461
-
462
- q_rv = f"SELECT * FROM [{target_table_name}] WHERE TRIM(Comp_std)=? AND TRIM(Comp_ver)=? AND TRIM(Base_std)=? AND TRIM(Base_ver)=?"
463
- df_rv = pd.read_sql(q_rv, conn_map, params=[base_std.strip(), base_ver.strip(), comp_std.strip(), comp_ver.strip()])
464
- except Exception as sql_e:
465
- conn_map.close()
466
- return pd.DataFrame({"Error": [f"매핑 장부 '{target_table_name}'을 여는 데 실패했습니다. 테이블 이름을 확인하세요: {str(sql_e)}"]})
467
- conn_map.close()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
468
 
469
- cols_fw_lower = {c.lower(): c for c in df_fw.columns}
470
-
471
- if 'base_type' in cols_fw_lower and 'comp_type' in cols_fw_lower:
472
- b_col = cols_fw_lower['base_type']
473
- c_col = cols_fw_lower['comp_type']
474
-
475
- df_fw[b_col] = df_fw[b_col].fillna('Main').astype(str).str.strip().str.upper()
476
- df_fw[c_col] = df_fw[c_col].fillna('Main').astype(str).str.strip().str.upper()
477
- df_fw = df_fw[(df_fw[b_col] == type_b.strip().upper()) & (df_fw[c_col] == type_c.strip().upper())]
478
-
479
- if not df_rv.empty:
480
- df_rv[b_col] = df_rv[b_col].fillna('Main').astype(str).str.strip().str.upper()
481
- df_rv[c_col] = df_rv[c_col].fillna('Main').astype(str).str.strip().str.upper()
482
- df_rv = df_rv[(df_rv[c_col] == type_b.strip().upper()) & (df_rv[b_col] == type_c.strip().upper())]
483
 
484
- b_sec = cols_fw_lower.get('base_section', 'Base_section')
485
- c_sec = cols_fw_lower.get('comp_section', 'Comp_section')
486
-
487
- if b_sec not in df_fw.columns or c_sec not in df_fw.columns:
488
- return pd.DataFrame({"Error": [f"'{target_table_name}' 장부에 '{b_sec}' 또는 '{c_sec}' 열이 없습니다. 대소문자를 확인하세요."]})
489
-
490
- df_fw = df_fw[[b_sec, c_sec]].rename(columns={b_sec: 'Base_section', c_sec: 'Comp_section'})
491
- if not df_rv.empty:
492
- df_rv = df_rv[[b_sec, c_sec]].rename(columns={b_sec: 'Comp_section', c_sec: 'Base_section'})
493
- else:
494
- df_rv = pd.DataFrame(columns=['Base_section', 'Comp_section'])
495
 
496
- df_mapping = pd.concat([df_fw, df_rv], ignore_index=True)
497
 
498
- if not df_mapping.empty:
499
- df_mapping['Base_section'] = df_mapping['Base_section'].astype(str).str.replace('\n', ',').str.split(',')
500
- df_mapping['Comp_section'] = df_mapping['Comp_section'].astype(str).str.replace('\n', ',').str.split(',')
501
- df_mapping = df_mapping.explode('Base_section').explode('Comp_section')
502
-
503
- df_mapping['Base_section'] = df_mapping['Base_section'].apply(clean_key_val)
504
- df_mapping['Comp_section'] = df_mapping['Comp_section'].apply(clean_key_val)
505
-
506
- df_mapping = df_mapping[(df_mapping['Base_section'] != "") & (df_mapping['Comp_section'] != "")]
507
- bridge = df_mapping.dropna().drop_duplicates()
508
- else:
509
- bridge = pd.DataFrame(columns=['Base_section', 'Comp_section'])
510
 
511
- # 💡 [요청하신 딱 그 부분 수정!]
512
- # 1순위: 장부에 적힌 매핑(bridge)을 그대로 씁니다.
513
- # 2순위: 장부가 비어있고 양쪽 법규/카테고리가 완벽히 같을 때만 양쪽 조항 전체(합집합)를 1:1로 묶습니다.
514
- if bridge.empty and base_std.strip().upper() == comp_std.strip().upper() and type_b.strip().upper() == type_c.strip().upper():
515
- all_keys = list(set(df_base['merge_key']).union(set(df_comp['merge_key'])))
516
- bridge = pd.DataFrame({'Base_section': all_keys, 'Comp_section': all_keys})
517
 
518
- rename_b = {c: f"{c}_{base_ver}" for c in df_base.columns if c != 'merge_key'}
519
- df_base = df_base.rename(columns=rename_b)
520
-
521
- rename_c = {c: f"{c}_{comp_ver}" for c in df_comp.columns if c != 'merge_key'}
522
- df_comp = df_comp.rename(columns=rename_c)
523
 
524
  df_base['base_idx'], df_comp['comp_idx'] = range(len(df_base)), range(len(df_comp))
 
525
  merged = pd.merge(bridge, df_base, left_on='Base_section', right_on='merge_key', how='outer')
 
526
  merged = pd.merge(merged, df_comp, left_on='Comp_section', right_on='merge_key', how='outer')
 
527
 
 
528
  merged['base_idx'] = merged['base_idx'].fillna(float('inf'))
 
529
  merged['comp_idx'] = merged['comp_idx'].fillna(float('inf'))
 
530
  merged = merged.sort_values(['base_idx', 'comp_idx'])
531
 
 
 
532
  result_rows = []
 
533
  for _, row in merged.iterrows():
 
534
  row_dict = {}
 
535
  has_b = not pd.isna(row.get('base_idx')) and row.get('base_idx') != float('inf')
 
536
  has_c = not pd.isna(row.get('comp_idx')) and row.get('comp_idx') != float('inf')
 
537
 
538
- for c in rename_b.values(): row_dict[c] = str(row[c]) if has_b and not pd.isna(row[c]) else ""
539
- for c in rename_c.values(): row_dict[c] = str(row[c]) if has_c and not pd.isna(row[c]) else ""
 
 
 
540
 
 
541
  if mapped_only:
542
- b_has_val = any(str(row_dict[c]).strip() not in ["", "nan", "None", "&nbsp;"] for c in rename_b.values())
543
- c_has_val = any(str(row_dict[c]).strip() not in ["", "nan", "None", "&nbsp;"] for c in rename_c.values())
544
-
 
 
545
  if not (b_has_val and c_has_val):
 
546
  continue
 
547
 
 
548
  if has_b and has_c:
549
- for orig_col in rename_b.keys():
550
- if ('description' in orig_col.lower() or '내용' in orig_col) and rename_c.get(orig_col) in row_dict:
551
- b_v, c_v = row_dict[rename_b[orig_col]], row_dict[rename_c[orig_col]]
 
 
 
 
552
  if b_v and c_v and "<img" not in b_v and "<img" not in c_v and b_v != c_v:
553
- row_dict[rename_b[orig_col]], row_dict[rename_c[orig_col]] = generate_html_diff(b_v, c_v)
 
 
554
  result_rows.append(row_dict)
555
 
 
 
556
  final_df = combine_code_desc(pd.DataFrame(result_rows))
557
 
 
 
558
  if final_df.empty:
 
559
  return pd.DataFrame({"Info": ["💡 조건에 맞는 데이터가 없습니다."]})
560
 
 
 
561
  if diff_only:
 
562
  mask = final_df.astype(str).apply(lambda col: col.str.contains('color:#ff4d4f|color:#2ecc71', case=False, regex=True)).any(axis=1)
 
563
  final_df = final_df[mask]
 
564
  if final_df.empty:
 
565
  return pd.DataFrame({"Info": ["💡 선택하신 조건 간에 변경된 내용이 없습니다. (100% 동일)"]})
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
566
 
 
 
 
 
 
567
  b_cols_final = [c for c in final_df.columns if c.endswith(f"_{base_ver}")]
 
568
  c_cols_final = [c for c in final_df.columns if c.endswith(f"_{comp_ver}")]
569
 
 
 
570
  final_df = apply_visual_merge(final_df, b_cols_final)
 
571
  final_df = apply_visual_merge(final_df, c_cols_final)
572
 
 
 
573
  return final_df
574
 
 
 
575
  return pd.DataFrame({"Info": ["조건을 선택하세요."]})
 
576
  except Exception as e:
 
577
  error_msg = str(e)
 
578
  if "database is locked" in error_msg.lower():
 
579
  return pd.DataFrame({"Error": ["🚨 DB가 잠겨있습니다! 켜놓으신 'DB Browser' 프로그램을 완전히 종료한 뒤 다시 조회해 주세요."]})
 
580
  return pd.DataFrame({"Error": [f"시스템 오류 발생: {error_msg}"]})
581
 
 
 
582
  # ==========================================
 
583
  # 4. UI Layout & Event Binding
 
584
  # ==========================================
 
585
  with gr.Blocks() as demo:
 
586
  gr.Markdown("# 📜 Regulation Viewer")
587
 
 
 
588
  with gr.Row():
 
589
  with gr.Accordion("📌 기준 법규", open=True):
 
590
  with gr.Column():
 
591
  base_standard = gr.Dropdown(label="Standard")
 
592
  base_version = gr.Dropdown(label="Version")
 
593
  base_status = gr.Textbox(label="Status", interactive=False, lines=1)
 
594
 
 
595
  with gr.Row(elem_classes="reset-row"):
 
596
  base_category = gr.Dropdown(label="Category", scale=4)
 
597
  base_reset_btn = gr.Button("↺ 초기화", scale=1, elem_classes="reset-btn")
 
598
 
 
599
  with gr.Accordion("🔄 비교 법규", open=False):
 
600
  with gr.Column():
 
601
  comp_standard = gr.Dropdown(label="Standard", choices=[])
 
602
  comp_version = gr.Dropdown(label="Version", choices=[])
 
603
  comp_status = gr.Textbox(label="Status", interactive=False, lines=1)
 
604
 
 
605
  with gr.Row(elem_classes="reset-row"):
 
606
  comp_category = gr.Dropdown(label="Category", choices=[], scale=4)
 
607
  comp_reset_btn = gr.Button("↺ 초기화", scale=1, elem_classes="reset-btn")
608
 
 
 
609
  with gr.Row(elem_id="search_row"):
 
610
  search_btn = gr.Button("🔍 조회", variant="primary", scale=10)
 
611
  mapped_only_cb = gr.Checkbox(label="🔗 매핑된 항목만 보기", value=False, elem_id="mapped_cb_item", container=False, scale=1)
 
612
  diff_filter_cb = gr.Checkbox(label="💡 변경된 내용만 보기", value=False, elem_id="diff_cb_item", container=False, scale=1)
613
 
 
 
614
  output_df = gr.Dataframe(wrap=True, interactive=False, datatype="html", max_height=800)
615
 
 
 
616
  demo.load(fn=load_initial_standards, inputs=None, outputs=base_standard)
617
 
 
 
618
  base_standard.change(fn=update_version_dropdown, inputs=[base_standard], outputs=[base_version])
 
619
  base_version.change(fn=update_base_category_dropdown, inputs=[base_standard, base_version], outputs=[base_category, base_status])
620
 
 
 
621
  base_version.change(fn=update_comp_standard_dropdown, inputs=[base_standard, base_version], outputs=[comp_standard])
 
622
  comp_standard.change(fn=update_comp_version_dropdown, inputs=[base_standard, base_version, comp_standard], outputs=[comp_version])
623
 
 
 
624
  comp_change_triggers = [base_standard, base_version, base_category, comp_standard, comp_version]
 
625
 
 
626
  base_category.change(fn=update_comp_category_dropdown, inputs=comp_change_triggers, outputs=[comp_category, comp_status])
 
627
  comp_version.change(fn=update_comp_category_dropdown, inputs=comp_change_triggers, outputs=[comp_category, comp_status])
628
 
 
 
629
  search_btn.click(
 
630
  fn=execute_unified_search,
 
631
  inputs=[base_standard, base_version, base_category, comp_standard, comp_version, comp_category, mapped_only_cb, diff_filter_cb],
 
632
  outputs=[output_df]
 
633
  )
 
634
 
 
635
  mapped_only_cb.change(
 
636
  fn=execute_unified_search,
 
637
  inputs=[base_standard, base_version, base_category, comp_standard, comp_version, comp_category, mapped_only_cb, diff_filter_cb],
 
638
  outputs=[output_df]
 
639
  )
640
 
 
 
641
  diff_filter_cb.change(
 
642
  fn=execute_unified_search,
 
643
  inputs=[base_standard, base_version, base_category, comp_standard, comp_version, comp_category, mapped_only_cb, diff_filter_cb],
 
644
  outputs=[output_df]
 
645
  )
646
 
 
 
647
  base_reset_btn.click(fn=reset_base_selections, inputs=None, outputs=[base_standard, base_version, base_category, base_status])
 
648
  comp_reset_btn.click(fn=reset_comp_selections, inputs=None, outputs=[comp_standard, comp_version, comp_category, comp_status])
649
 
 
 
650
  # ==========================================
 
651
  # 5. Application Styling (CSS)
 
652
  # ==========================================
 
653
  css = """
 
654
  .reset-row {
 
655
  align-items: flex-end !important;
 
656
  margin-bottom: 5px !important;
 
657
  }
 
658
  .reset-btn {
 
659
  margin-bottom: 10px !important;
 
660
  }
 
661
  table {
 
662
  table-layout: auto !important;
 
663
  width: max-content !important;
 
664
  min-width: 100% !important;
 
665
  }
 
666
  th, td {
 
667
  min-width: 150px;
 
668
  }
 
669
  table:has(th:nth-last-child(2):first-child),
 
670
  table:has(th:nth-last-child(3):first-child),
 
671
  table:has(th:nth-last-child(4):first-child),
 
672
  table:has(th:nth-last-child(5):first-child),
 
673
  table:has(th:nth-last-child(6):first-child) {
 
674
  table-layout: fixed !important;
 
675
  width: 100% !important;
 
676
  }
 
677
  table th:nth-last-child(2):first-child, table td:nth-last-child(2):first-child { width: 15% !important; min-width: 0 !important; }
 
678
  table th:nth-last-child(1), table td:nth-last-child(1) { width: 85% !important; min-width: 0 !important; }
679
 
 
 
680
  table th:nth-child(1):nth-last-child(3), table td:nth-child(1):nth-last-child(3) { width: 20% !important; min-width: 0 !important; }
 
681
  table th:nth-child(2):nth-last-child(2), table td:nth-child(2):nth-last-child(2) { width: 30% !important; min-width: 0 !important; }
 
682
  table th:nth-child(3):nth-last-child(1), table td:nth-child(3):nth-last-child(1) { width: 50% !important; min-width: 0 !important; }
683
 
 
 
684
  table th:nth-child(1):nth-last-child(4), table td:nth-child(1):nth-last-child(4) { width: 10% !important; min-width: 0 !important; }
 
685
  table th:nth-child(2):nth-last-child(3), table td:nth-child(2):nth-last-child(3) { width: 40% !important; min-width: 0 !important; }
 
686
  table th:nth-child(3):nth-last-child(2), table td:nth-child(3):nth-last-child(2) { width: 10% !important; min-width: 0 !important; }
 
687
  table th:nth-child(4):nth-last-child(1), table td:nth-child(4):nth-last-child(1) { width: 40% !important; min-width: 0 !important; }
688
 
 
 
689
  table th:nth-child(1):nth-last-child(5), table td:nth-child(1):nth-last-child(5) { width: 8% !important; min-width: 0 !important; }
 
690
  table th:nth-child(2):nth-last-child(4), table td:nth-child(2):nth-last-child(4) { width: 8% !important; min-width: 0 !important; }
 
691
  table th:nth-child(3):nth-last-child(3), table td:nth-child(3):nth-last-child(3) { width: 8% !important; min-width: 0 !important; }
 
692
  table th:nth-child(4):nth-last-child(2), table td:nth-child(4):nth-last-child(2) { width: 8% !important; min-width: 0 !important; }
 
693
  table th:nth-child(5):nth-last-child(1), table td:nth-child(5):nth-last-child(1) { width: 68% !important; min-width: 0 !important; }
694
 
 
 
695
  table th:nth-child(1):nth-last-child(6), table td:nth-child(1):nth-last-child(6) { width: 8% !important; min-width: 0 !important; }
 
696
  table th:nth-child(2):nth-last-child(5), table td:nth-child(2):nth-last-child(5) { width: 15% !important; min-width: 0 !important; }
 
697
  table th:nth-child(3):nth-last-child(4), table td:nth-child(3):nth-last-child(4) { width: 27% !important; min-width: 0 !important; }
 
698
  table th:nth-child(4):nth-last-child(3), table td:nth-child(4):nth-last-child(3) { width: 8% !important; min-width: 0 !important; }
 
699
  table th:nth-child(5):nth-last-child(2), table td:nth-child(5):nth-last-child(2) { width: 15% !important; min-width: 0 !important; }
 
700
  table th:nth-child(6):nth-last-child(1), table td:nth-child(6):nth-last-child(1) { width: 27% !important; min-width: 0 !important; }
701
 
 
 
702
  thead th {
 
703
  font-size: 18px !important;
 
704
  position: sticky;
 
705
  top: 0;
 
706
  background: white;
 
707
  z-index: 10;
 
708
  }
 
709
  .dataframe {
 
710
  max-height: none !important;
 
711
  overflow-y: visible !important;
 
712
  overflow-x: auto !important;
 
713
  display: block;
 
714
  }
 
715
  .dataframe > div {
 
716
  max-height: none !important;
 
717
  overflow: visible !important;
 
718
  }
 
719
  td {
 
720
  font-size: 18px !important;
 
721
  white-space: pre-wrap !important;
 
722
  word-break: keep-all !important;
 
723
  line-height: 1.6;
 
724
  padding: 10px;
 
725
  vertical-align: top !important;
 
726
  text-align: left !important;
 
727
  }
 
728
  td img {
 
729
  display: block;
 
730
  max-width: none !important;
 
731
  }
 
732
  #search_row {
 
733
  align-items: center !important;
 
734
  margin-bottom: 5px !important;
 
735
  }
 
736
  #diff_cb_item, #mapped_cb_item {
 
737
  margin-top: 0 !important;
 
738
  padding-left: 15px !important;
 
739
  width: max-content !important;
 
740
  min-width: max-content !important;
 
741
  flex-grow: 0 !important;
 
742
  }
 
743
  """
744
 
 
 
745
  if __name__ == "__main__":
 
746
  demo.launch(
 
747
  theme=gr.themes.Soft(),
 
748
  share=True,
 
749
  css=css
 
750
  )
 
6
  import base64
7
  import difflib
8
 
9
+
10
  # ==========================================
11
  # 1. Environment Setup & Data Helpers
12
  # ==========================================
13
+
14
  UPLOAD_DIR = "uploaded_dbs"
15
+
16
  if not os.path.exists(UPLOAD_DIR):
17
+
18
  os.makedirs(UPLOAD_DIR)
19
 
20
+
21
+
22
  db_registry = []
23
 
24
+
25
+
26
  def convert_blob_to_html_img(blob_data):
27
+
28
  if blob_data is None or pd.isna(blob_data):
29
+
30
  return ""
31
+
32
  try:
33
+
34
  if isinstance(blob_data, (bytes, bytearray)):
35
+
36
  encoded = base64.b64encode(blob_data).decode('utf-8')
37
+
38
  return f'''
39
+
40
  <img src="data:image/png;base64,{encoded}"
41
+
42
  style="width: 40%;
43
+
44
  max-height: 300px;
45
+
46
  object-fit: contain;
47
+
48
  display: block;
49
+
50
  margin: 10px 0;">
51
+
52
  '''
53
+
54
  return str(blob_data)
55
+
56
  except Exception:
57
+
58
  return str(blob_data)
59
 
60
+
61
+
62
  def decode_sqlite_text(x):
63
+
64
  try:
65
+
66
  return x.decode('utf-8')
67
+
68
  except UnicodeDecodeError:
69
+
70
  return x
71
 
72
+
73
+
74
  def fetch_available_standards():
75
+
76
  global db_registry
77
+
78
  db_registry = []
79
+
80
  for file_name in os.listdir(UPLOAD_DIR):
81
+
82
  if file_name.endswith(".db"):
83
+
84
  name = file_name.replace(".db", "")
85
+
86
  parts = name.split("_")
87
+
88
  if len(parts) >= 2:
89
+
90
  db_registry.append({
91
+
92
  "path": os.path.join(UPLOAD_DIR, file_name),
93
+
94
  "standard": parts[0],
95
+
96
  "version": parts[1]
97
+
98
  })
99
+
100
  return sorted(list(set([d["standard"] for d in db_registry])))
101
 
102
+
103
+
104
  def fetch_database_records(std, ver, cat, table_type):
105
+
106
  try:
107
+
108
  db_path = os.path.join(UPLOAD_DIR, f"{std}_{ver}.db")
109
+
110
  conn = sqlite3.connect(db_path)
111
+
112
  conn.text_factory = decode_sqlite_text
113
+
114
 
115
+
116
  tables = pd.read_sql("SELECT name FROM sqlite_master WHERE type='table';", conn)['name'].tolist()
117
+
118
  valid_tables = [t for t in tables if t.lower() not in ['mapping_registry', 'mapping_table', 'table_config', 'sqlite_sequence']]
119
+
120
 
121
+
122
  main_table = None
123
+
124
  if table_type and table_type.upper() != "MAIN":
125
+
126
  expected_name = f"{std}_{ver}_{table_type}"
127
+
128
  for t in valid_tables:
129
+
130
  if t.lower() == expected_name.lower() or t.lower() == table_type.lower():
131
+
132
  main_table = t
133
+
134
  break
135
+
136
 
137
+
138
  if not main_table:
139
+
140
  main_table = f"{std}_{ver}"
141
+
142
  if main_table not in valid_tables:
143
+
144
  main_table = valid_tables[0] if valid_tables else None
145
+
146
 
147
+
148
  if not main_table:
149
+
150
  conn.close()
151
+
152
  return pd.DataFrame({"Error": ["데이터 테이블을 찾을 수 없습니다."]}), []
153
+
154
 
155
+
156
  cols = pd.read_sql(f"PRAGMA table_info([{main_table}])", conn)['name'].tolist()
157
+
158
  lower_cols = [c.lower() for c in cols]
159
+
160
 
161
+
162
  conn_map = sqlite3.connect(os.path.join(UPLOAD_DIR, "mapping.db"))
163
+
164
  config_df = pd.read_sql("SELECT * FROM Table_Config WHERE TRIM(Table_Type)=?", conn_map, params=[table_type.strip()])
165
+
166
  conn_map.close()
167
+
168
 
169
+
170
  if config_df.empty:
171
+
172
  return pd.DataFrame({"Error": [f"Table_Config에서 '{table_type}' 설정을 찾을 수 없습니다."]}), []
173
+
174
 
175
+
176
  matched_config = None
177
+
178
  for _, row in config_df.iterrows():
179
+
180
  anchors_test = [x.strip().lower() for x in str(row['Anchor_Column']).split(',')]
181
+
182
  if any(a in lower_cols for a in anchors_test):
183
+
184
  matched_config = row
185
+
186
  break
187
+
188
 
189
+
190
  if matched_config is None:
191
+
192
  matched_config = config_df.iloc[0]
193
+
194
 
195
+
196
  anchors = [x.strip() for x in matched_config['Anchor_Column'].split(',')]
197
+
198
  displays = [x.strip() for x in matched_config['Display_Columns'].split(',')] if pd.notna(matched_config['Display_Columns']) else anchors
199
+
200
 
201
+
202
  query = f"SELECT * FROM [{main_table}]"
203
+
204
  conditions = []
205
+
206
 
207
+
208
  if cat and cat != "ALL":
209
+
210
  if "." in cat and 'chapter' in lower_cols and 'category' in lower_cols:
211
+
212
  ch, ca = cat.split(".", 1)
213
+
214
  ch_col = cols[lower_cols.index('chapter')]
215
+
216
  ca_col = cols[lower_cols.index('category')]
217
+
218
  conditions.append(f"[{ch_col}] = '{ch}' AND [{ca_col}] = '{ca}'")
219
+
220
  elif 'category' in lower_cols:
221
+
222
  ca_col = cols[lower_cols.index('category')]
223
+
224
  conditions.append(f"[{ca_col}] = '{cat}'")
225
+
226
 
227
+
228
  if conditions:
229
+
230
  query += " WHERE " + " AND ".join(conditions)
231
+
232
 
233
+
234
  df = pd.read_sql(query, conn)
235
+
236
  conn.close()
237
+
238
 
239
+
240
  real_anchors = [c for c in df.columns if any(a.lower() == c.lower() for a in anchors)]
241
+
242
 
243
+
244
  final_cols = []
245
+
246
  for d in displays:
247
+
248
  for c in df.columns:
249
+
250
  if d.lower() == c.lower():
251
+
252
  if c not in final_cols:
253
+
254
  final_cols.append(c)
255
+
256
  break
257
+
258
 
259
+
260
  for ra in real_anchors:
261
+
262
  if ra not in final_cols:
263
+
264
  final_cols.append(ra)
265
+
266
 
267
+
268
  if not final_cols:
269
+
270
  return df, real_anchors
271
+
272
 
273
+
274
  for c in final_cols:
275
+
276
  df[c] = df[c].apply(convert_blob_to_html_img)
277
+
278
 
279
+
280
  return df[final_cols], real_anchors
281
+
282
 
283
+
284
  except Exception as e:
285
+
286
  import traceback
287
+
288
  traceback.print_exc()
289
+
290
  return pd.DataFrame({"Error": [f"데이터 로드 오류: {str(e)}"]}), []
291
 
292
+
293
+
294
  def generate_html_diff(text1, text2):
295
+
296
  try:
297
+
298
  s1, s2 = str(text1), str(text2)
299
+
300
 
301
+
302
  if len(s1) > 1000 or len(s2) > 1000:
303
+
304
  return s1, s2
305
+
306
 
307
+
308
  words1, words2 = s1.split(), s2.split()
309
+
310
  if not words1 or not words2:
311
+
312
  return s1, s2
313
+
314
 
315
+
316
  common_words = set(words1) & set(words2)
317
+
318
  if len(common_words) / min(len(words1), len(words2)) < 0.05:
319
+
320
  return s1, s2
321
 
322
+
323
+
324
  matcher = difflib.SequenceMatcher(None, words1, words2)
325
+
326
  res1, res2 = [], []
327
+
328
 
329
+
330
  for tag, i1, i2, j1, j2 in matcher.get_opcodes():
331
+
332
  if tag == 'replace':
333
+
334
  res1.append(f"<span style='color:#ff4d4f; font-weight:bold;'>{' '.join(words1[i1:i2])}</span>")
335
+
336
  res2.append(f"<span style='color:#2ecc71; font-weight:bold;'>{' '.join(words2[j1:j2])}</span>")
337
+
338
  elif tag == 'delete':
339
+
340
  res1.append(f"<span style='color:#ff4d4f; font-weight:bold;'>{' '.join(words1[i1:i2])}</span>")
341
+
342
  elif tag == 'insert':
343
+
344
  res2.append(f"<span style='color:#2ecc71; font-weight:bold;'>{' '.join(words2[j1:j2])}</span>")
345
+
346
  elif tag == 'equal':
347
+
348
  res1.append(' '.join(words1[i1:i2]))
349
+
350
  res2.append(' '.join(words2[j1:j2]))
351
+
352
 
353
+
354
  return " ".join(res1), " ".join(res2)
355
+
356
  except Exception:
357
+
358
  return text1, text2
359
 
360
+
361
+
362
  # ==========================================
363
+
364
  # 2. UI Component Handlers
365
+
366
  # ==========================================
367
+
368
  def load_initial_standards():
369
+
370
  return gr.Dropdown(choices=fetch_available_standards())
371
 
372
+
373
+
374
  def update_version_dropdown(standard):
375
+
376
  if not standard:
377
+
378
  return gr.Dropdown(choices=[])
379
+
380
  versions = []
381
+
382
  for file_name in os.listdir(UPLOAD_DIR):
383
+
384
  if file_name.startswith(standard + "_") and file_name.endswith(".db"):
385
+
386
  versions.append(file_name.replace(standard + "_", "").replace(".db", ""))
387
+
388
  return gr.Dropdown(choices=sorted(list(set(versions))))
389
 
390
+
391
+
392
  def update_base_category_dropdown(standard, version):
393
+
394
  if not standard or not version:
395
+
396
  return gr.update(choices=[], value=None, interactive=False), gr.update(value="")
397
 
398
+
399
+
400
  choices = ["ALL"]
401
+
402
  status_value = ""
403
+
404
  db_path = os.path.join(UPLOAD_DIR, f"{standard}_{version}.db")
405
+
406
 
407
+
408
  if not os.path.exists(db_path):
409
+
410
  return gr.update(choices=choices), gr.update(value="")
411
 
412
+
413
+
414
  try:
415
+
416
  conn = sqlite3.connect(db_path)
417
+
418
  tables = pd.read_sql("SELECT name FROM sqlite_master WHERE type='table';", conn)['name'].tolist()
419
+
420
  valid_tables = [t for t in tables if t.lower() not in ['mapping_registry', 'mapping_table', 'table_config', 'sqlite_sequence']]
421
+
422
  main_table = f"{standard}_{version}"
423
+
424
  if main_table not in valid_tables:
425
+
426
  main_table = valid_tables[0] if valid_tables else None
427
 
428
+
429
+
430
  if main_table:
431
+
432
  try:
433
+
434
  cols_check = pd.read_sql(f"PRAGMA table_info([{main_table}])", conn)['name'].tolist()
435
+
436
  if "Status" in cols_check or "status" in cols_check:
437
+
438
  status_df = pd.read_sql(f"SELECT Status FROM [{main_table}] WHERE Status IS NOT NULL AND Status != '' LIMIT 1", conn)
439
+
440
  if not status_df.empty:
441
+
442
  status_value = str(status_df.iloc[0]['Status'])
443
+
444
  except Exception:
445
+
446
  pass
447
 
448
+
449
+
450
  cols = pd.read_sql(f"PRAGMA table_info([{main_table}])", conn)['name'].tolist()
451
+
452
  lower_cols = [c.lower() for c in cols]
453
+
454
  if 'chapter' in lower_cols and 'category' in lower_cols:
455
+
456
  ch_col = cols[lower_cols.index('chapter')]
457
+
458
  ca_col = cols[lower_cols.index('category')]
459
+
460
 
461
+
462
  df = pd.read_sql(f"SELECT DISTINCT [{ch_col}], [{ca_col}] FROM [{main_table}]", conn)
463
+
464
  for _, row in df.iterrows():
465
+
466
  ch = str(row[ch_col]).strip()
467
+
468
  ca = str(row[ca_col]).strip()
469
+
470
  if ch and ca and ch.lower() not in ['none', 'nan'] and ca.lower() not in ['none', 'nan']:
471
+
472
  choices.append(f"{ch}.{ca}")
473
 
474
+
475
+
476
  pattern = re.compile(f"^{standard}[_\\s-]*{version}[_\\s-]*", re.IGNORECASE)
477
+
478
  for t in valid_tables:
479
+
480
  if t == main_table: continue
481
+
482
  short_name = pattern.sub("", t).strip(" _")
483
+
484
  if short_name and short_name not in choices:
485
+
486
  choices.append(short_name)
487
+
488
  elif t not in choices:
489
+
490
  choices.append(t)
491
 
492
+
493
+
494
  conn.close()
495
+
496
  except Exception:
497
+
498
  pass
499
+
500
 
501
+
502
  return gr.update(choices=choices, value=None, interactive=True), gr.update(value=status_value)
503
 
504
+
505
+
506
  def update_comp_standard_dropdown(base_std, base_ver):
507
+
508
  if not base_std or not base_ver:
509
+
510
  return gr.Dropdown(choices=[], value=None, interactive=False)
511
 
512
+
513
+
514
  try:
515
+
516
  conn = sqlite3.connect(os.path.join(UPLOAD_DIR, "mapping.db"))
517
+
518
  query = "SELECT DISTINCT TRIM(Comp_std) AS Comp_std FROM Mapping_registry WHERE TRIM(Base_std)=TRIM(?) AND TRIM(Base_ver)=TRIM(?)"
519
+
520
  df = pd.read_sql(query, conn, params=[base_std, base_ver])
521
+
522
  conn.close()
523
 
524
+
525
+
526
  mapped_stds = sorted(df['Comp_std'].dropna().unique().tolist()) if not df.empty else []
527
+
528
  return gr.update(choices=mapped_stds, value=None, interactive=bool(mapped_stds))
529
+
530
  except Exception:
531
+
532
  return gr.update(choices=[], value=None, interactive=False)
533
 
534
+
535
+
536
  def update_comp_version_dropdown(base_std, base_ver, comp_std):
537
+
538
  if not all([base_std, base_ver, comp_std]):
539
+
540
  return gr.update(choices=[], value=None, interactive=False)
541
 
542
+
543
+
544
  try:
545
+
546
  conn = sqlite3.connect(os.path.join(UPLOAD_DIR, "mapping.db"))
547
+
548
  query = "SELECT DISTINCT TRIM(Comp_ver) AS Comp_ver FROM Mapping_registry WHERE TRIM(Base_std)=TRIM(?) AND TRIM(Base_ver)=TRIM(?) AND TRIM(Comp_std)=TRIM(?)"
549
+
550
  df = pd.read_sql(query, conn, params=[base_std, base_ver, comp_std])
551
+
552
  conn.close()
553
 
554
+
555
+
556
  mapped_vers = sorted(df['Comp_ver'].dropna().unique().tolist()) if not df.empty else []
557
+
558
  return gr.update(choices=mapped_vers, value=None, interactive=bool(mapped_vers))
559
+
560
  except Exception:
561
+
562
  return gr.update(choices=[], value=None, interactive=False)
563
 
564
+
565
+
566
  def update_comp_category_dropdown(base_std, base_ver, base_cat, comp_std, comp_ver):
567
+
568
  if not all([base_std, base_ver, base_cat, comp_std, comp_ver]):
569
+
570
  return gr.update(choices=[], value=None, interactive=False), gr.update(value="")
571
 
572
+
573
+
574
  base_type = "Main" if not base_cat or base_cat == "ALL" or "." in base_cat else base_cat
575
+
576
  status_value = ""
577
+
578
  comp_db_path = os.path.join(UPLOAD_DIR, f"{comp_std}_{comp_ver}.db")
579
+
580
  c_main_table = None
581
 
582
+
583
+
584
  try:
585
+
586
  if os.path.exists(comp_db_path):
587
+
588
  c_conn = sqlite3.connect(comp_db_path)
589
+
590
  c_tables = pd.read_sql("SELECT name FROM sqlite_master WHERE type='table';", c_conn)['name'].tolist()
591
+
592
  c_valid_tables = [t for t in c_tables if t.lower() not in ['mapping_registry', 'mapping_table', 'table_config', 'sqlite_sequence']]
593
+
594
  c_main_table = f"{comp_std}_{comp_ver}"
595
+
596
  if c_main_table not in c_valid_tables:
597
+
598
  c_main_table = c_valid_tables[0] if c_valid_tables else None
599
+
600
 
601
+
602
  if c_main_table:
603
+
604
  try:
605
+
606
  cols_check = pd.read_sql(f"PRAGMA table_info([{c_main_table}])", c_conn)['name'].tolist()
607
+
608
  if "Status" in cols_check or "status" in cols_check:
609
+
610
  status_df = pd.read_sql(f"SELECT Status FROM [{c_main_table}] WHERE Status IS NOT NULL AND Status != '' LIMIT 1", c_conn)
611
+
612
  if not status_df.empty:
613
+
614
  status_value = str(status_df.iloc[0]['Status'])
615
+
616
  except Exception:
617
+
618
  pass
619
+
620
  c_conn.close()
621
 
622
+
623
+
624
  conn = sqlite3.connect(os.path.join(UPLOAD_DIR, "mapping.db"))
625
+
626
  query = """
627
+
628
  SELECT DISTINCT TRIM(Comp_Type) AS Comp_Type
629
+
630
  FROM Mapping_registry
631
+
632
  WHERE TRIM(Base_std)=TRIM(?) AND TRIM(Base_ver)=TRIM(?) AND TRIM(Base_Type)=TRIM(?) AND TRIM(Comp_std)=TRIM(?) AND TRIM(Comp_ver)=TRIM(?)
633
+
634
  """
635
+
636
  df = pd.read_sql(query, conn, params=[base_std, base_ver, base_type, comp_std, comp_ver])
637
+
638
  conn.close()
639
 
640
+
641
+
642
  allowed_types = df['Comp_Type'].dropna().tolist()
643
+
644
  if not allowed_types:
645
+
646
  return gr.update(choices=[], value=None), gr.update(value=status_value)
647
 
648
+
649
+
650
  final_choices = []
651
+
652
  if "Main" in allowed_types:
653
+
654
  final_choices.append("ALL")
655
+
656
  if os.path.exists(comp_db_path) and c_main_table:
657
+
658
  c_conn = sqlite3.connect(comp_db_path)
659
+
660
  cols = pd.read_sql(f"PRAGMA table_info([{c_main_table}])", c_conn)['name'].tolist()
661
+
662
  lower_cols = [c.lower() for c in cols]
663
+
664
  if 'chapter' in lower_cols and 'category' in lower_cols:
665
+
666
  ch_col = cols[lower_cols.index('chapter')]
667
+
668
  ca_col = cols[lower_cols.index('category')]
669
+
670
  c_df = pd.read_sql(f"SELECT DISTINCT [{ch_col}], [{ca_col}] FROM [{c_main_table}]", c_conn)
671
+
672
  for _, row in c_df.iterrows():
673
+
674
  ch = str(row[ch_col]).strip()
675
+
676
  ca = str(row[ca_col]).strip()
677
+
678
  if ch and ca and ch.lower() not in ['none', 'nan'] and ca.lower() not in ['none', 'nan']:
679
+
680
  final_choices.append(f"{ch}.{ca}")
681
+
682
  c_conn.close()
683
+
684
 
685
+
686
  for t in allowed_types:
687
+
688
  if t != "Main": final_choices.append(t)
689
 
690
+
691
+
692
  return gr.update(choices=final_choices, value=final_choices[0] if final_choices else None, interactive=True), gr.update(value=status_value)
693
+
694
 
695
+
696
  except Exception:
697
+
698
  return gr.update(choices=[], value=None), gr.update(value="")
699
 
700
+
701
+
702
  def reset_base_selections():
703
+
704
  return gr.update(value=None), gr.update(choices=[], value=None), gr.update(choices=[], value=None), gr.update(value="")
705
 
706
+
707
+
708
  def reset_comp_selections():
709
+
710
  return gr.update(value=None), gr.update(choices=[], value=None), gr.update(choices=[], value=None), gr.update(value="")
711
 
712
+
713
+
714
  # ==========================================
715
+
716
  # 3. Core Search Logic
717
+
718
  # ==========================================
719
+
720
  def execute_unified_search(base_std, base_ver, base_cat, comp_std, comp_ver, comp_cat, mapped_only, diff_only):
721
+
722
  try:
723
+
724
  def get_type_by_cat(cat):
725
+
726
  if not cat or cat == "ALL": return "Main"
727
+
728
  if "." in cat: return "Main"
729
+
730
  return cat
731
 
732
+
733
+
734
  type_b = get_type_by_cat(base_cat)
735
+
736
  type_c = get_type_by_cat(comp_cat)
737
+
738
 
739
+
740
  def apply_visual_merge(df, cols):
741
+
742
  if not df.empty and len(cols) > 1:
743
+
744
  is_dup = pd.Series([True] * len(df), index=df.index)
745
+
746
  for col in cols:
747
+
748
  if col in df.columns:
749
+
750
  curr = df[col].astype(str).str.strip()
751
+
752
  match = (curr == curr.shift(1)) & (~curr.isin(["", "nan", "None", "&nbsp;"]))
753
+
754
  is_dup = is_dup & match
755
+
756
  df.loc[is_dup, col] = "&nbsp;"
757
+
758
  return df
759
 
760
+
761
+
762
  def combine_code_desc(df):
763
+
764
  cols = list(df.columns)
765
+
766
  new_cols = []
767
+
768
  processed = set()
769
+
770
  for col in cols:
771
+
772
  if col in processed: continue
773
+
774
  if "_Code" in col:
775
+
776
  desc_col = col.replace("_Code", "_Description")
777
+
778
  if desc_col in cols:
779
+
780
  new_col_name = col.replace("_Code", "")
781
+
782
  def combine_cells(row):
783
+
784
  c, d = str(row[col]).strip(), str(row[desc_col]).strip()
785
+
786
  if c in ["nan", "None", "", "&nbsp;"]: return d
787
+
788
  if d in ["nan", "None", "", "&nbsp;"]: return f"<span style='font-weight:bold; color:#1a73e8;'>{c}</span>"
789
+
790
  return f"<span style='font-weight:bold; color:#1a73e8; display:block; margin-bottom:4px;'>{c}</span>{d}"
791
+
792
  df[new_col_name] = df.apply(combine_cells, axis=1)
793
+
794
  new_cols.append(new_col_name)
795
+
796
  processed.update([col, desc_col])
797
+
798
  else: new_cols.append(col)
799
+
800
  elif "_Description" in col:
801
+
802
  if col.replace("_Description", "_Code") not in cols: new_cols.append(col)
803
+
804
  else: new_cols.append(col)
805
+
806
  return df[new_cols]
807
 
808
+
809
+
810
  if base_std and base_ver and base_cat and (not comp_std or not comp_ver or not comp_cat):
811
+
812
  df, _ = fetch_database_records(base_std, base_ver, base_cat, type_b)
813
+
814
  if "Error" in df.columns: return df
815
+
 
 
 
 
 
816
  return apply_visual_merge(df, df.columns)
817
 
818
+
819
+
820
  if all([base_std, base_ver, base_cat, comp_std, comp_ver, comp_cat]):
821
+
822
  df_base, real_anchors_b = fetch_database_records(base_std, base_ver, base_cat, type_b)
823
+
824
  df_comp, real_anchors_c = fetch_database_records(comp_std, comp_ver, comp_cat, type_c)
825
 
826
+
827
+
828
  if "Error" in df_base.columns: return df_base
829
+
830
  if "Error" in df_comp.columns: return df_comp
831
 
832
+
833
+
834
  for ra in real_anchors_b:
835
+
836
  if ra not in df_base.columns:
837
+
838
  return pd.DataFrame({"Error": [f"기준 열(Anchor) '{ra}'이(가) 기준 데이터에 존재하지 않습니다. Table_Config를 확인하세요."]})
839
+
840
  for ra in real_anchors_c:
841
+
842
  if ra not in df_comp.columns:
843
+
844
  return pd.DataFrame({"Error": [f"비교 열(Anchor) '{ra}'이(가) 비교 데이터에 존재하지 않습니다. Table_Config를 확인하세요."]})
845
 
846
+
847
+
848
  def clean_key_val(v):
849
+
850
  s = str(v).strip()
851
+
852
  if s.endswith('.0') and s[:-2].isdigit():
853
+
854
  s = s[:-2]
855
+
856
  return s.replace(" ", "")
857
 
 
 
858
 
 
 
 
 
 
 
 
 
 
 
 
 
 
859
 
860
+ internal_rename_b = {c: f"{c}_INTERNAL_BASE" for c in df_base.columns if c != 'merge_key'}
861
+
862
+ internal_rename_c = {c: f"{c}_INTERNAL_COMP" for c in df_comp.columns if c != 'merge_key'}
863
+
864
+
865
+
866
+ df_base['merge_key'] = df_base[real_anchors_b].apply(lambda row: '-'.join([clean_key_val(x) for x in row]), axis=1)
867
+
868
+ df_comp['merge_key'] = df_comp[real_anchors_c].apply(lambda row: '-'.join([clean_key_val(x) for x in row]), axis=1)
869
+
870
+
871
+
872
+ is_same_std = (base_std.strip().upper() == comp_std.strip().upper() and type_b.strip().upper() == type_c.strip().upper())
873
+
874
+
875
+
876
+ if is_same_std:
877
+
878
+ all_keys = list(set(df_base['merge_key']).union(set(df_comp['merge_key'])))
879
+
880
+ bridge = pd.DataFrame({'Base_section': all_keys, 'Comp_section': all_keys})
881
+
882
+ else:
883
+
884
+ conn_map = sqlite3.connect(os.path.join(UPLOAD_DIR, "mapping.db"))
885
+
886
+ registry_query = """
887
+
888
+ SELECT Target_Table FROM Mapping_registry
889
+
890
+ WHERE TRIM(Base_std)=? AND TRIM(Base_ver)=? AND TRIM(Comp_std)=? AND TRIM(Comp_ver)=?
891
+
892
+ LIMIT 1
893
+
894
+ """
895
+
896
+ reg_df = pd.read_sql(registry_query, conn_map, params=[base_std.strip(), base_ver.strip(), comp_std.strip(), comp_ver.strip()])
897
+
898
+
899
+
900
+ target_table_name = "Mapping_table"
901
+
902
+ if not reg_df.empty and pd.notna(reg_df.iloc[0]['Target_Table']):
903
+
904
+ val = str(reg_df.iloc[0]['Target_Table']).strip()
905
+
906
+ if val and val.lower() not in ["none", "nan"]:
907
+
908
+ target_table_name = val
909
+
910
+
911
+
912
+ try:
913
+
914
+ q_fw = f"SELECT * FROM [{target_table_name}] WHERE TRIM(Base_std)=? AND TRIM(Base_ver)=? AND TRIM(Comp_std)=? AND TRIM(Comp_ver)=?"
915
+
916
+ df_fw = pd.read_sql(q_fw, conn_map, params=[base_std.strip(), base_ver.strip(), comp_std.strip(), comp_ver.strip()])
917
+
918
+
919
+
920
+ q_rv = f"SELECT * FROM [{target_table_name}] WHERE TRIM(Comp_std)=? AND TRIM(Comp_ver)=? AND TRIM(Base_std)=? AND TRIM(Base_ver)=?"
921
+
922
+ df_rv = pd.read_sql(q_rv, conn_map, params=[base_std.strip(), base_ver.strip(), comp_std.strip(), comp_ver.strip()])
923
+
924
+ except Exception as sql_e:
925
+
926
+ conn_map.close()
927
+
928
+ return pd.DataFrame({"Error": [f"매핑 '{target_table_name}'을 여는 데 실패했습니다. 테이블 이름을 확인하세요: {str(sql_e)}"]})
929
+
930
+ conn_map.close()
931
+
932
+
933
+
934
+ cols_fw_lower = {c.lower(): c for c in df_fw.columns}
935
+
936
+
937
+
938
+ if 'base_type' in cols_fw_lower and 'comp_type' in cols_fw_lower:
939
+
940
+ b_col = cols_fw_lower['base_type']
941
+
942
+ c_col = cols_fw_lower['comp_type']
943
+
944
+
945
+
946
+ df_fw[b_col] = df_fw[b_col].fillna('Main').astype(str).str.strip().str.upper()
947
+
948
+ df_fw[c_col] = df_fw[c_col].fillna('Main').astype(str).str.strip().str.upper()
949
+
950
+ df_fw = df_fw[(df_fw[b_col] == type_b.strip().upper()) & (df_fw[c_col] == type_c.strip().upper())]
951
+
952
+
953
+
954
+ if not df_rv.empty:
955
+
956
+ df_rv[b_col] = df_rv[b_col].fillna('Main').astype(str).str.strip().str.upper()
957
+
958
+ df_rv[c_col] = df_rv[c_col].fillna('Main').astype(str).str.strip().str.upper()
959
+
960
+ df_rv = df_rv[(df_rv[c_col] == type_b.strip().upper()) & (df_rv[b_col] == type_c.strip().upper())]
961
+
962
+
963
+
964
+ b_sec = cols_fw_lower.get('base_section', 'Base_section')
965
+
966
+ c_sec = cols_fw_lower.get('comp_section', 'Comp_section')
967
+
968
+
969
+
970
+ if b_sec not in df_fw.columns or c_sec not in df_fw.columns:
971
+
972
+ return pd.DataFrame({"Error": [f"'{target_table_name}' 에 '{b_sec}' 또는 '{c_sec}' 열이 없습니다. 대소문자를 확인하세요."]})
973
+
974
+
975
+
976
+ df_fw = df_fw[[b_sec, c_sec]].rename(columns={b_sec: 'Base_section', c_sec: 'Comp_section'})
977
+
978
+ if not df_rv.empty:
979
+
980
+ df_rv = df_rv[[b_sec, c_sec]].rename(columns={b_sec: 'Comp_section', c_sec: 'Base_section'})
981
+
982
+ else:
983
+
984
+ df_rv = pd.DataFrame(columns=['Base_section', 'Comp_section'])
985
+
986
+
987
+
988
+ df_mapping = pd.concat([df_fw, df_rv], ignore_index=True)
989
+
990
+
991
+
992
+ if not df_mapping.empty:
993
+
994
+ df_mapping['Base_section'] = df_mapping['Base_section'].astype(str).str.replace('\n', ',').str.split(',')
995
+
996
+ df_mapping['Comp_section'] = df_mapping['Comp_section'].astype(str).str.replace('\n', ',').str.split(',')
997
+
998
+ df_mapping = df_mapping.explode('Base_section').explode('Comp_section')
999
+
1000
+
1001
+
1002
+ df_mapping['Base_section'] = df_mapping['Base_section'].apply(clean_key_val)
1003
+
1004
+ df_mapping['Comp_section'] = df_mapping['Comp_section'].apply(clean_key_val)
1005
+
1006
+
1007
+
1008
+ df_mapping = df_mapping[(df_mapping['Base_section'] != "") & (df_mapping['Comp_section'] != "")]
1009
+
1010
+ bridge = df_mapping.dropna().drop_duplicates()
1011
+
1012
+ else:
1013
+
1014
+ bridge = pd.DataFrame(columns=['Base_section', 'Comp_section'])
1015
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1016
 
 
 
 
 
 
 
 
 
 
 
 
1017
 
1018
+ df_base = df_base.rename(columns=internal_rename_b)
1019
 
1020
+ df_comp = df_comp.rename(columns=internal_rename_c)
 
 
 
 
 
 
 
 
 
 
 
1021
 
 
 
 
 
 
 
1022
 
 
 
 
 
 
1023
 
1024
  df_base['base_idx'], df_comp['comp_idx'] = range(len(df_base)), range(len(df_comp))
1025
+
1026
  merged = pd.merge(bridge, df_base, left_on='Base_section', right_on='merge_key', how='outer')
1027
+
1028
  merged = pd.merge(merged, df_comp, left_on='Comp_section', right_on='merge_key', how='outer')
1029
+
1030
 
1031
+
1032
  merged['base_idx'] = merged['base_idx'].fillna(float('inf'))
1033
+
1034
  merged['comp_idx'] = merged['comp_idx'].fillna(float('inf'))
1035
+
1036
  merged = merged.sort_values(['base_idx', 'comp_idx'])
1037
 
1038
+
1039
+
1040
  result_rows = []
1041
+
1042
  for _, row in merged.iterrows():
1043
+
1044
  row_dict = {}
1045
+
1046
  has_b = not pd.isna(row.get('base_idx')) and row.get('base_idx') != float('inf')
1047
+
1048
  has_c = not pd.isna(row.get('comp_idx')) and row.get('comp_idx') != float('inf')
1049
+
1050
 
1051
+
1052
+ for c in internal_rename_b.values(): row_dict[c] = str(row[c]) if has_b and not pd.isna(row[c]) else ""
1053
+
1054
+ for c in internal_rename_c.values(): row_dict[c] = str(row[c]) if has_c and not pd.isna(row[c]) else ""
1055
+
1056
 
1057
+
1058
  if mapped_only:
1059
+
1060
+ b_has_val = any(str(row_dict[c]).strip() not in ["", "nan", "None", "&nbsp;"] for c in internal_rename_b.values())
1061
+
1062
+ c_has_val = any(str(row_dict[c]).strip() not in ["", "nan", "None", "&nbsp;"] for c in internal_rename_c.values())
1063
+
1064
  if not (b_has_val and c_has_val):
1065
+
1066
  continue
1067
+
1068
 
1069
+
1070
  if has_b and has_c:
1071
+
1072
+ for orig_col in internal_rename_b.keys():
1073
+
1074
+ if ('description' in orig_col.lower() or '내용' in orig_col) and internal_rename_c.get(orig_col) in row_dict:
1075
+
1076
+ b_v, c_v = row_dict[internal_rename_b[orig_col]], row_dict[internal_rename_c[orig_col]]
1077
+
1078
  if b_v and c_v and "<img" not in b_v and "<img" not in c_v and b_v != c_v:
1079
+
1080
+ row_dict[internal_rename_b[orig_col]], row_dict[internal_rename_c[orig_col]] = generate_html_diff(b_v, c_v)
1081
+
1082
  result_rows.append(row_dict)
1083
 
1084
+
1085
+
1086
  final_df = combine_code_desc(pd.DataFrame(result_rows))
1087
 
1088
+
1089
+
1090
  if final_df.empty:
1091
+
1092
  return pd.DataFrame({"Info": ["💡 조건에 맞는 데이터가 없습니다."]})
1093
 
1094
+
1095
+
1096
  if diff_only:
1097
+
1098
  mask = final_df.astype(str).apply(lambda col: col.str.contains('color:#ff4d4f|color:#2ecc71', case=False, regex=True)).any(axis=1)
1099
+
1100
  final_df = final_df[mask]
1101
+
1102
  if final_df.empty:
1103
+
1104
  return pd.DataFrame({"Info": ["💡 선택하신 조건 간에 변경된 내용이 없습니다. (100% 동일)"]})
1105
+
1106
+
1107
+
1108
+ final_rename_map = {}
1109
+
1110
+ for col in final_df.columns:
1111
+
1112
+ if col.endswith("_INTERNAL_BASE"):
1113
+
1114
+ final_rename_map[col] = f"{col.replace('_INTERNAL_BASE', '')}_{base_ver}"
1115
+
1116
+ elif col.endswith("_INTERNAL_COMP"):
1117
+
1118
+ final_rename_map[col] = f"{col.replace('_INTERNAL_COMP', '')}_{comp_ver}"
1119
+
1120
 
1121
+
1122
+ final_df = final_df.rename(columns=final_rename_map)
1123
+
1124
+
1125
+
1126
  b_cols_final = [c for c in final_df.columns if c.endswith(f"_{base_ver}")]
1127
+
1128
  c_cols_final = [c for c in final_df.columns if c.endswith(f"_{comp_ver}")]
1129
 
1130
+
1131
+
1132
  final_df = apply_visual_merge(final_df, b_cols_final)
1133
+
1134
  final_df = apply_visual_merge(final_df, c_cols_final)
1135
 
1136
+
1137
+
1138
  return final_df
1139
 
1140
+
1141
+
1142
  return pd.DataFrame({"Info": ["조건을 선택하세요."]})
1143
+
1144
  except Exception as e:
1145
+
1146
  error_msg = str(e)
1147
+
1148
  if "database is locked" in error_msg.lower():
1149
+
1150
  return pd.DataFrame({"Error": ["🚨 DB가 잠겨있습니다! 켜놓으신 'DB Browser' 프로그램을 완전히 종료한 뒤 다시 조회해 주세요."]})
1151
+
1152
  return pd.DataFrame({"Error": [f"시스템 오류 발생: {error_msg}"]})
1153
 
1154
+
1155
+
1156
  # ==========================================
1157
+
1158
  # 4. UI Layout & Event Binding
1159
+
1160
  # ==========================================
1161
+
1162
  with gr.Blocks() as demo:
1163
+
1164
  gr.Markdown("# 📜 Regulation Viewer")
1165
 
1166
+
1167
+
1168
  with gr.Row():
1169
+
1170
  with gr.Accordion("📌 기준 법규", open=True):
1171
+
1172
  with gr.Column():
1173
+
1174
  base_standard = gr.Dropdown(label="Standard")
1175
+
1176
  base_version = gr.Dropdown(label="Version")
1177
+
1178
  base_status = gr.Textbox(label="Status", interactive=False, lines=1)
1179
+
1180
 
1181
+
1182
  with gr.Row(elem_classes="reset-row"):
1183
+
1184
  base_category = gr.Dropdown(label="Category", scale=4)
1185
+
1186
  base_reset_btn = gr.Button("↺ 초기화", scale=1, elem_classes="reset-btn")
1187
+
1188
 
1189
+
1190
  with gr.Accordion("🔄 비교 법규", open=False):
1191
+
1192
  with gr.Column():
1193
+
1194
  comp_standard = gr.Dropdown(label="Standard", choices=[])
1195
+
1196
  comp_version = gr.Dropdown(label="Version", choices=[])
1197
+
1198
  comp_status = gr.Textbox(label="Status", interactive=False, lines=1)
1199
+
1200
 
1201
+
1202
  with gr.Row(elem_classes="reset-row"):
1203
+
1204
  comp_category = gr.Dropdown(label="Category", choices=[], scale=4)
1205
+
1206
  comp_reset_btn = gr.Button("↺ 초기화", scale=1, elem_classes="reset-btn")
1207
 
1208
+
1209
+
1210
  with gr.Row(elem_id="search_row"):
1211
+
1212
  search_btn = gr.Button("🔍 조회", variant="primary", scale=10)
1213
+
1214
  mapped_only_cb = gr.Checkbox(label="🔗 매핑된 항목만 보기", value=False, elem_id="mapped_cb_item", container=False, scale=1)
1215
+
1216
  diff_filter_cb = gr.Checkbox(label="💡 변경된 내용만 보기", value=False, elem_id="diff_cb_item", container=False, scale=1)
1217
 
1218
+
1219
+
1220
  output_df = gr.Dataframe(wrap=True, interactive=False, datatype="html", max_height=800)
1221
 
1222
+
1223
+
1224
  demo.load(fn=load_initial_standards, inputs=None, outputs=base_standard)
1225
 
1226
+
1227
+
1228
  base_standard.change(fn=update_version_dropdown, inputs=[base_standard], outputs=[base_version])
1229
+
1230
  base_version.change(fn=update_base_category_dropdown, inputs=[base_standard, base_version], outputs=[base_category, base_status])
1231
 
1232
+
1233
+
1234
  base_version.change(fn=update_comp_standard_dropdown, inputs=[base_standard, base_version], outputs=[comp_standard])
1235
+
1236
  comp_standard.change(fn=update_comp_version_dropdown, inputs=[base_standard, base_version, comp_standard], outputs=[comp_version])
1237
 
1238
+
1239
+
1240
  comp_change_triggers = [base_standard, base_version, base_category, comp_standard, comp_version]
1241
+
1242
 
1243
+
1244
  base_category.change(fn=update_comp_category_dropdown, inputs=comp_change_triggers, outputs=[comp_category, comp_status])
1245
+
1246
  comp_version.change(fn=update_comp_category_dropdown, inputs=comp_change_triggers, outputs=[comp_category, comp_status])
1247
 
1248
+
1249
+
1250
  search_btn.click(
1251
+
1252
  fn=execute_unified_search,
1253
+
1254
  inputs=[base_standard, base_version, base_category, comp_standard, comp_version, comp_category, mapped_only_cb, diff_filter_cb],
1255
+
1256
  outputs=[output_df]
1257
+
1258
  )
1259
+
1260
 
1261
+
1262
  mapped_only_cb.change(
1263
+
1264
  fn=execute_unified_search,
1265
+
1266
  inputs=[base_standard, base_version, base_category, comp_standard, comp_version, comp_category, mapped_only_cb, diff_filter_cb],
1267
+
1268
  outputs=[output_df]
1269
+
1270
  )
1271
 
1272
+
1273
+
1274
  diff_filter_cb.change(
1275
+
1276
  fn=execute_unified_search,
1277
+
1278
  inputs=[base_standard, base_version, base_category, comp_standard, comp_version, comp_category, mapped_only_cb, diff_filter_cb],
1279
+
1280
  outputs=[output_df]
1281
+
1282
  )
1283
 
1284
+
1285
+
1286
  base_reset_btn.click(fn=reset_base_selections, inputs=None, outputs=[base_standard, base_version, base_category, base_status])
1287
+
1288
  comp_reset_btn.click(fn=reset_comp_selections, inputs=None, outputs=[comp_standard, comp_version, comp_category, comp_status])
1289
 
1290
+
1291
+
1292
  # ==========================================
1293
+
1294
  # 5. Application Styling (CSS)
1295
+
1296
  # ==========================================
1297
+
1298
  css = """
1299
+
1300
  .reset-row {
1301
+
1302
  align-items: flex-end !important;
1303
+
1304
  margin-bottom: 5px !important;
1305
+
1306
  }
1307
+
1308
  .reset-btn {
1309
+
1310
  margin-bottom: 10px !important;
1311
+
1312
  }
1313
+
1314
  table {
1315
+
1316
  table-layout: auto !important;
1317
+
1318
  width: max-content !important;
1319
+
1320
  min-width: 100% !important;
1321
+
1322
  }
1323
+
1324
  th, td {
1325
+
1326
  min-width: 150px;
1327
+
1328
  }
1329
+
1330
  table:has(th:nth-last-child(2):first-child),
1331
+
1332
  table:has(th:nth-last-child(3):first-child),
1333
+
1334
  table:has(th:nth-last-child(4):first-child),
1335
+
1336
  table:has(th:nth-last-child(5):first-child),
1337
+
1338
  table:has(th:nth-last-child(6):first-child) {
1339
+
1340
  table-layout: fixed !important;
1341
+
1342
  width: 100% !important;
1343
+
1344
  }
1345
+
1346
  table th:nth-last-child(2):first-child, table td:nth-last-child(2):first-child { width: 15% !important; min-width: 0 !important; }
1347
+
1348
  table th:nth-last-child(1), table td:nth-last-child(1) { width: 85% !important; min-width: 0 !important; }
1349
 
1350
+
1351
+
1352
  table th:nth-child(1):nth-last-child(3), table td:nth-child(1):nth-last-child(3) { width: 20% !important; min-width: 0 !important; }
1353
+
1354
  table th:nth-child(2):nth-last-child(2), table td:nth-child(2):nth-last-child(2) { width: 30% !important; min-width: 0 !important; }
1355
+
1356
  table th:nth-child(3):nth-last-child(1), table td:nth-child(3):nth-last-child(1) { width: 50% !important; min-width: 0 !important; }
1357
 
1358
+
1359
+
1360
  table th:nth-child(1):nth-last-child(4), table td:nth-child(1):nth-last-child(4) { width: 10% !important; min-width: 0 !important; }
1361
+
1362
  table th:nth-child(2):nth-last-child(3), table td:nth-child(2):nth-last-child(3) { width: 40% !important; min-width: 0 !important; }
1363
+
1364
  table th:nth-child(3):nth-last-child(2), table td:nth-child(3):nth-last-child(2) { width: 10% !important; min-width: 0 !important; }
1365
+
1366
  table th:nth-child(4):nth-last-child(1), table td:nth-child(4):nth-last-child(1) { width: 40% !important; min-width: 0 !important; }
1367
 
1368
+
1369
+
1370
  table th:nth-child(1):nth-last-child(5), table td:nth-child(1):nth-last-child(5) { width: 8% !important; min-width: 0 !important; }
1371
+
1372
  table th:nth-child(2):nth-last-child(4), table td:nth-child(2):nth-last-child(4) { width: 8% !important; min-width: 0 !important; }
1373
+
1374
  table th:nth-child(3):nth-last-child(3), table td:nth-child(3):nth-last-child(3) { width: 8% !important; min-width: 0 !important; }
1375
+
1376
  table th:nth-child(4):nth-last-child(2), table td:nth-child(4):nth-last-child(2) { width: 8% !important; min-width: 0 !important; }
1377
+
1378
  table th:nth-child(5):nth-last-child(1), table td:nth-child(5):nth-last-child(1) { width: 68% !important; min-width: 0 !important; }
1379
 
1380
+
1381
+
1382
  table th:nth-child(1):nth-last-child(6), table td:nth-child(1):nth-last-child(6) { width: 8% !important; min-width: 0 !important; }
1383
+
1384
  table th:nth-child(2):nth-last-child(5), table td:nth-child(2):nth-last-child(5) { width: 15% !important; min-width: 0 !important; }
1385
+
1386
  table th:nth-child(3):nth-last-child(4), table td:nth-child(3):nth-last-child(4) { width: 27% !important; min-width: 0 !important; }
1387
+
1388
  table th:nth-child(4):nth-last-child(3), table td:nth-child(4):nth-last-child(3) { width: 8% !important; min-width: 0 !important; }
1389
+
1390
  table th:nth-child(5):nth-last-child(2), table td:nth-child(5):nth-last-child(2) { width: 15% !important; min-width: 0 !important; }
1391
+
1392
  table th:nth-child(6):nth-last-child(1), table td:nth-child(6):nth-last-child(1) { width: 27% !important; min-width: 0 !important; }
1393
 
1394
+
1395
+
1396
  thead th {
1397
+
1398
  font-size: 18px !important;
1399
+
1400
  position: sticky;
1401
+
1402
  top: 0;
1403
+
1404
  background: white;
1405
+
1406
  z-index: 10;
1407
+
1408
  }
1409
+
1410
  .dataframe {
1411
+
1412
  max-height: none !important;
1413
+
1414
  overflow-y: visible !important;
1415
+
1416
  overflow-x: auto !important;
1417
+
1418
  display: block;
1419
+
1420
  }
1421
+
1422
  .dataframe > div {
1423
+
1424
  max-height: none !important;
1425
+
1426
  overflow: visible !important;
1427
+
1428
  }
1429
+
1430
  td {
1431
+
1432
  font-size: 18px !important;
1433
+
1434
  white-space: pre-wrap !important;
1435
+
1436
  word-break: keep-all !important;
1437
+
1438
  line-height: 1.6;
1439
+
1440
  padding: 10px;
1441
+
1442
  vertical-align: top !important;
1443
+
1444
  text-align: left !important;
1445
+
1446
  }
1447
+
1448
  td img {
1449
+
1450
  display: block;
1451
+
1452
  max-width: none !important;
1453
+
1454
  }
1455
+
1456
  #search_row {
1457
+
1458
  align-items: center !important;
1459
+
1460
  margin-bottom: 5px !important;
1461
+
1462
  }
1463
+
1464
  #diff_cb_item, #mapped_cb_item {
1465
+
1466
  margin-top: 0 !important;
1467
+
1468
  padding-left: 15px !important;
1469
+
1470
  width: max-content !important;
1471
+
1472
  min-width: max-content !important;
1473
+
1474
  flex-grow: 0 !important;
1475
+
1476
  }
1477
+
1478
  """
1479
 
1480
+
1481
+
1482
  if __name__ == "__main__":
1483
+
1484
  demo.launch(
1485
+
1486
  theme=gr.themes.Soft(),
1487
+
1488
  share=True,
1489
+
1490
  css=css
1491
+
1492
  )