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

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +62 -802
app.py CHANGED
@@ -6,1487 +6,747 @@ import re
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
  )
 
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
+ # 💡 Table_Config의 Display_Columns만 100% 신뢰하여 추출합니다.
128
  final_cols = []
 
129
  for d in displays:
 
130
  for c in df.columns:
 
131
  if d.lower() == c.lower():
 
132
  if c not in final_cols:
 
133
  final_cols.append(c)
 
134
  break
 
135
 
 
136
  for ra in real_anchors:
 
137
  if ra not in final_cols:
 
138
  final_cols.append(ra)
 
139
 
 
140
  if not final_cols:
 
141
  return df, real_anchors
 
142
 
 
143
  for c in final_cols:
 
144
  df[c] = df[c].apply(convert_blob_to_html_img)
 
145
 
 
146
  return df[final_cols], real_anchors
 
147
 
 
148
  except Exception as e:
 
149
  import traceback
 
150
  traceback.print_exc()
 
151
  return pd.DataFrame({"Error": [f"데이터 로드 오류: {str(e)}"]}), []
152
 
 
 
153
  def generate_html_diff(text1, text2):
 
154
  try:
 
155
  s1, s2 = str(text1), str(text2)
 
156
 
 
157
  if len(s1) > 1000 or len(s2) > 1000:
 
158
  return s1, s2
 
159
 
 
160
  words1, words2 = s1.split(), s2.split()
 
161
  if not words1 or not words2:
 
162
  return s1, s2
 
163
 
 
164
  common_words = set(words1) & set(words2)
 
165
  if len(common_words) / min(len(words1), len(words2)) < 0.05:
 
166
  return s1, s2
167
 
 
 
168
  matcher = difflib.SequenceMatcher(None, words1, words2)
 
169
  res1, res2 = [], []
 
170
 
 
171
  for tag, i1, i2, j1, j2 in matcher.get_opcodes():
 
172
  if tag == 'replace':
 
173
  res1.append(f"<span style='color:#ff4d4f; font-weight:bold;'>{' '.join(words1[i1:i2])}</span>")
 
174
  res2.append(f"<span style='color:#2ecc71; font-weight:bold;'>{' '.join(words2[j1:j2])}</span>")
 
175
  elif tag == 'delete':
 
176
  res1.append(f"<span style='color:#ff4d4f; font-weight:bold;'>{' '.join(words1[i1:i2])}</span>")
 
177
  elif tag == 'insert':
 
178
  res2.append(f"<span style='color:#2ecc71; font-weight:bold;'>{' '.join(words2[j1:j2])}</span>")
 
179
  elif tag == 'equal':
 
180
  res1.append(' '.join(words1[i1:i2]))
 
181
  res2.append(' '.join(words2[j1:j2]))
 
182
 
 
183
  return " ".join(res1), " ".join(res2)
 
184
  except Exception:
 
185
  return text1, text2
186
 
 
 
187
  # ==========================================
 
188
  # 2. UI Component Handlers
 
189
  # ==========================================
 
190
  def load_initial_standards():
 
191
  return gr.Dropdown(choices=fetch_available_standards())
192
 
 
 
193
  def update_version_dropdown(standard):
 
194
  if not standard:
 
195
  return gr.Dropdown(choices=[])
 
196
  versions = []
 
197
  for file_name in os.listdir(UPLOAD_DIR):
 
198
  if file_name.startswith(standard + "_") and file_name.endswith(".db"):
 
199
  versions.append(file_name.replace(standard + "_", "").replace(".db", ""))
 
200
  return gr.Dropdown(choices=sorted(list(set(versions))))
201
 
 
 
202
  def update_base_category_dropdown(standard, version):
 
203
  if not standard or not version:
 
204
  return gr.update(choices=[], value=None, interactive=False), gr.update(value="")
205
 
 
 
206
  choices = ["ALL"]
 
207
  status_value = ""
 
208
  db_path = os.path.join(UPLOAD_DIR, f"{standard}_{version}.db")
 
209
 
 
210
  if not os.path.exists(db_path):
 
211
  return gr.update(choices=choices), gr.update(value="")
212
 
 
 
213
  try:
 
214
  conn = sqlite3.connect(db_path)
 
215
  tables = pd.read_sql("SELECT name FROM sqlite_master WHERE type='table';", conn)['name'].tolist()
 
216
  valid_tables = [t for t in tables if t.lower() not in ['mapping_registry', 'mapping_table', 'table_config', 'sqlite_sequence']]
 
217
  main_table = f"{standard}_{version}"
 
218
  if main_table not in valid_tables:
 
219
  main_table = valid_tables[0] if valid_tables else None
220
 
 
 
221
  if main_table:
 
222
  try:
 
223
  cols_check = pd.read_sql(f"PRAGMA table_info([{main_table}])", conn)['name'].tolist()
 
224
  if "Status" in cols_check or "status" in cols_check:
 
225
  status_df = pd.read_sql(f"SELECT Status FROM [{main_table}] WHERE Status IS NOT NULL AND Status != '' LIMIT 1", conn)
 
226
  if not status_df.empty:
 
227
  status_value = str(status_df.iloc[0]['Status'])
 
228
  except Exception:
 
229
  pass
230
 
 
 
231
  cols = pd.read_sql(f"PRAGMA table_info([{main_table}])", conn)['name'].tolist()
 
232
  lower_cols = [c.lower() for c in cols]
 
233
  if 'chapter' in lower_cols and 'category' in lower_cols:
 
234
  ch_col = cols[lower_cols.index('chapter')]
 
235
  ca_col = cols[lower_cols.index('category')]
 
236
 
 
237
  df = pd.read_sql(f"SELECT DISTINCT [{ch_col}], [{ca_col}] FROM [{main_table}]", conn)
 
238
  for _, row in df.iterrows():
 
239
  ch = str(row[ch_col]).strip()
 
240
  ca = str(row[ca_col]).strip()
 
241
  if ch and ca and ch.lower() not in ['none', 'nan'] and ca.lower() not in ['none', 'nan']:
 
242
  choices.append(f"{ch}.{ca}")
243
 
 
 
244
  pattern = re.compile(f"^{standard}[_\\s-]*{version}[_\\s-]*", re.IGNORECASE)
 
245
  for t in valid_tables:
 
246
  if t == main_table: continue
 
247
  short_name = pattern.sub("", t).strip(" _")
 
248
  if short_name and short_name not in choices:
 
249
  choices.append(short_name)
 
250
  elif t not in choices:
 
251
  choices.append(t)
252
 
 
 
253
  conn.close()
 
254
  except Exception:
 
255
  pass
 
256
 
 
257
  return gr.update(choices=choices, value=None, interactive=True), gr.update(value=status_value)
258
 
 
 
259
  def update_comp_standard_dropdown(base_std, base_ver):
 
260
  if not base_std or not base_ver:
 
261
  return gr.Dropdown(choices=[], value=None, interactive=False)
262
 
 
 
263
  try:
 
264
  conn = sqlite3.connect(os.path.join(UPLOAD_DIR, "mapping.db"))
 
265
  query = "SELECT DISTINCT TRIM(Comp_std) AS Comp_std FROM Mapping_registry WHERE TRIM(Base_std)=TRIM(?) AND TRIM(Base_ver)=TRIM(?)"
 
266
  df = pd.read_sql(query, conn, params=[base_std, base_ver])
 
267
  conn.close()
268
 
 
 
269
  mapped_stds = sorted(df['Comp_std'].dropna().unique().tolist()) if not df.empty else []
 
270
  return gr.update(choices=mapped_stds, value=None, interactive=bool(mapped_stds))
 
271
  except Exception:
 
272
  return gr.update(choices=[], value=None, interactive=False)
273
 
 
 
274
  def update_comp_version_dropdown(base_std, base_ver, comp_std):
 
275
  if not all([base_std, base_ver, comp_std]):
 
276
  return gr.update(choices=[], value=None, interactive=False)
277
 
 
 
278
  try:
 
279
  conn = sqlite3.connect(os.path.join(UPLOAD_DIR, "mapping.db"))
 
280
  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(?)"
 
281
  df = pd.read_sql(query, conn, params=[base_std, base_ver, comp_std])
 
282
  conn.close()
283
 
 
 
284
  mapped_vers = sorted(df['Comp_ver'].dropna().unique().tolist()) if not df.empty else []
 
285
  return gr.update(choices=mapped_vers, value=None, interactive=bool(mapped_vers))
 
286
  except Exception:
 
287
  return gr.update(choices=[], value=None, interactive=False)
288
 
 
 
289
  def update_comp_category_dropdown(base_std, base_ver, base_cat, comp_std, comp_ver):
 
290
  if not all([base_std, base_ver, base_cat, comp_std, comp_ver]):
 
291
  return gr.update(choices=[], value=None, interactive=False), gr.update(value="")
292
 
 
 
293
  base_type = "Main" if not base_cat or base_cat == "ALL" or "." in base_cat else base_cat
 
294
  status_value = ""
 
295
  comp_db_path = os.path.join(UPLOAD_DIR, f"{comp_std}_{comp_ver}.db")
 
296
  c_main_table = None
297
 
 
 
298
  try:
 
299
  if os.path.exists(comp_db_path):
 
300
  c_conn = sqlite3.connect(comp_db_path)
 
301
  c_tables = pd.read_sql("SELECT name FROM sqlite_master WHERE type='table';", c_conn)['name'].tolist()
 
302
  c_valid_tables = [t for t in c_tables if t.lower() not in ['mapping_registry', 'mapping_table', 'table_config', 'sqlite_sequence']]
 
303
  c_main_table = f"{comp_std}_{comp_ver}"
 
304
  if c_main_table not in c_valid_tables:
 
305
  c_main_table = c_valid_tables[0] if c_valid_tables else None
 
306
 
 
307
  if c_main_table:
 
308
  try:
 
309
  cols_check = pd.read_sql(f"PRAGMA table_info([{c_main_table}])", c_conn)['name'].tolist()
 
310
  if "Status" in cols_check or "status" in cols_check:
 
311
  status_df = pd.read_sql(f"SELECT Status FROM [{c_main_table}] WHERE Status IS NOT NULL AND Status != '' LIMIT 1", c_conn)
 
312
  if not status_df.empty:
 
313
  status_value = str(status_df.iloc[0]['Status'])
 
314
  except Exception:
 
315
  pass
 
316
  c_conn.close()
317
 
 
 
318
  conn = sqlite3.connect(os.path.join(UPLOAD_DIR, "mapping.db"))
 
319
  query = """
 
320
  SELECT DISTINCT TRIM(Comp_Type) AS Comp_Type
 
321
  FROM Mapping_registry
 
322
  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(?)
 
323
  """
 
324
  df = pd.read_sql(query, conn, params=[base_std, base_ver, base_type, comp_std, comp_ver])
 
325
  conn.close()
326
 
 
 
327
  allowed_types = df['Comp_Type'].dropna().tolist()
 
328
  if not allowed_types:
 
329
  return gr.update(choices=[], value=None), gr.update(value=status_value)
330
 
 
 
331
  final_choices = []
 
332
  if "Main" in allowed_types:
 
333
  final_choices.append("ALL")
 
334
  if os.path.exists(comp_db_path) and c_main_table:
 
335
  c_conn = sqlite3.connect(comp_db_path)
 
336
  cols = pd.read_sql(f"PRAGMA table_info([{c_main_table}])", c_conn)['name'].tolist()
 
337
  lower_cols = [c.lower() for c in cols]
 
338
  if 'chapter' in lower_cols and 'category' in lower_cols:
 
339
  ch_col = cols[lower_cols.index('chapter')]
 
340
  ca_col = cols[lower_cols.index('category')]
 
341
  c_df = pd.read_sql(f"SELECT DISTINCT [{ch_col}], [{ca_col}] FROM [{c_main_table}]", c_conn)
 
342
  for _, row in c_df.iterrows():
 
343
  ch = str(row[ch_col]).strip()
 
344
  ca = str(row[ca_col]).strip()
 
345
  if ch and ca and ch.lower() not in ['none', 'nan'] and ca.lower() not in ['none', 'nan']:
 
346
  final_choices.append(f"{ch}.{ca}")
 
347
  c_conn.close()
 
348
 
 
349
  for t in allowed_types:
 
350
  if t != "Main": final_choices.append(t)
351
 
 
 
352
  return gr.update(choices=final_choices, value=final_choices[0] if final_choices else None, interactive=True), gr.update(value=status_value)
 
353
 
 
354
  except Exception:
 
355
  return gr.update(choices=[], value=None), gr.update(value="")
356
 
 
 
357
  def reset_base_selections():
 
358
  return gr.update(value=None), gr.update(choices=[], value=None), gr.update(choices=[], value=None), gr.update(value="")
359
 
 
 
360
  def reset_comp_selections():
 
361
  return gr.update(value=None), gr.update(choices=[], value=None), gr.update(choices=[], value=None), gr.update(value="")
362
 
 
 
363
  # ==========================================
 
364
  # 3. Core Search Logic
 
365
  # ==========================================
 
366
  def execute_unified_search(base_std, base_ver, base_cat, comp_std, comp_ver, comp_cat, mapped_only, diff_only):
 
367
  try:
 
368
  def get_type_by_cat(cat):
 
369
  if not cat or cat == "ALL": return "Main"
 
370
  if "." in cat: return "Main"
 
371
  return cat
372
 
 
 
373
  type_b = get_type_by_cat(base_cat)
 
374
  type_c = get_type_by_cat(comp_cat)
 
375
 
 
376
  def apply_visual_merge(df, cols):
 
377
  if not df.empty and len(cols) > 1:
 
378
  is_dup = pd.Series([True] * len(df), index=df.index)
 
379
  for col in cols:
 
380
  if col in df.columns:
 
381
  curr = df[col].astype(str).str.strip()
 
382
  match = (curr == curr.shift(1)) & (~curr.isin(["", "nan", "None", "&nbsp;"]))
 
383
  is_dup = is_dup & match
 
384
  df.loc[is_dup, col] = "&nbsp;"
 
385
  return df
386
 
 
 
387
  def combine_code_desc(df):
 
388
  cols = list(df.columns)
 
389
  new_cols = []
 
390
  processed = set()
 
391
  for col in cols:
 
392
  if col in processed: continue
 
393
  if "_Code" in col:
 
394
  desc_col = col.replace("_Code", "_Description")
 
395
  if desc_col in cols:
 
396
  new_col_name = col.replace("_Code", "")
 
397
  def combine_cells(row):
 
398
  c, d = str(row[col]).strip(), str(row[desc_col]).strip()
 
399
  if c in ["nan", "None", "", "&nbsp;"]: return d
 
400
  if d in ["nan", "None", "", "&nbsp;"]: return f"<span style='font-weight:bold; color:#1a73e8;'>{c}</span>"
 
401
  return f"<span style='font-weight:bold; color:#1a73e8; display:block; margin-bottom:4px;'>{c}</span>{d}"
 
402
  df[new_col_name] = df.apply(combine_cells, axis=1)
 
403
  new_cols.append(new_col_name)
 
404
  processed.update([col, desc_col])
 
405
  else: new_cols.append(col)
 
406
  elif "_Description" in col:
 
407
  if col.replace("_Description", "_Code") not in cols: new_cols.append(col)
 
408
  else: new_cols.append(col)
 
409
  return df[new_cols]
410
 
 
 
411
  if base_std and base_ver and base_cat and (not comp_std or not comp_ver or not comp_cat):
 
412
  df, _ = fetch_database_records(base_std, base_ver, base_cat, type_b)
 
413
  if "Error" in df.columns: return df
414
+
415
  return apply_visual_merge(df, df.columns)
416
 
 
 
417
  if all([base_std, base_ver, base_cat, comp_std, comp_ver, comp_cat]):
 
418
  df_base, real_anchors_b = fetch_database_records(base_std, base_ver, base_cat, type_b)
 
419
  df_comp, real_anchors_c = fetch_database_records(comp_std, comp_ver, comp_cat, type_c)
420
 
 
 
421
  if "Error" in df_base.columns: return df_base
 
422
  if "Error" in df_comp.columns: return df_comp
423
 
 
 
424
  for ra in real_anchors_b:
 
425
  if ra not in df_base.columns:
 
426
  return pd.DataFrame({"Error": [f"기준 열(Anchor) '{ra}'이(가) 기준 데이터에 존재하지 않습니다. Table_Config를 확인하세요."]})
 
427
  for ra in real_anchors_c:
 
428
  if ra not in df_comp.columns:
 
429
  return pd.DataFrame({"Error": [f"비교 열(Anchor) '{ra}'이(가) 비교 데이터에 존재하지 않습니다. Table_Config를 확인하세요."]})
430
 
 
 
431
  def clean_key_val(v):
 
432
  s = str(v).strip()
 
433
  if s.endswith('.0') and s[:-2].isdigit():
 
434
  s = s[:-2]
 
435
  return s.replace(" ", "")
436
 
 
 
437
  internal_rename_b = {c: f"{c}_INTERNAL_BASE" for c in df_base.columns if c != 'merge_key'}
 
438
  internal_rename_c = {c: f"{c}_INTERNAL_COMP" for c in df_comp.columns if c != 'merge_key'}
 
439
 
 
440
  df_base['merge_key'] = df_base[real_anchors_b].apply(lambda row: '-'.join([clean_key_val(x) for x in row]), axis=1)
 
441
  df_comp['merge_key'] = df_comp[real_anchors_c].apply(lambda row: '-'.join([clean_key_val(x) for x in row]), axis=1)
442
 
443
+ conn_map = sqlite3.connect(os.path.join(UPLOAD_DIR, "mapping.db"))
444
+ registry_query = """
445
+ SELECT Target_Table FROM Mapping_registry
446
+ WHERE TRIM(Base_std)=? AND TRIM(Base_ver)=? AND TRIM(Comp_std)=? AND TRIM(Comp_ver)=?
447
+ LIMIT 1
448
+ """
449
+ reg_df = pd.read_sql(registry_query, conn_map, params=[base_std.strip(), base_ver.strip(), comp_std.strip(), comp_ver.strip()])
450
+
451
+ target_table_name = "Mapping_table"
452
+ if not reg_df.empty and pd.notna(reg_df.iloc[0]['Target_Table']):
453
+ val = str(reg_df.iloc[0]['Target_Table']).strip()
454
+ if val and val.lower() not in ["none", "nan"]:
455
+ target_table_name = val
456
 
457
+ try:
458
+ q_fw = f"SELECT * FROM [{target_table_name}] WHERE TRIM(Base_std)=? AND TRIM(Base_ver)=? AND TRIM(Comp_std)=? AND TRIM(Comp_ver)=?"
459
+ df_fw = pd.read_sql(q_fw, conn_map, params=[base_std.strip(), base_ver.strip(), comp_std.strip(), comp_ver.strip()])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
460
 
461
+ q_rv = f"SELECT * FROM [{target_table_name}] WHERE TRIM(Comp_std)=? AND TRIM(Comp_ver)=? AND TRIM(Base_std)=? AND TRIM(Base_ver)=?"
462
+ df_rv = pd.read_sql(q_rv, conn_map, params=[base_std.strip(), base_ver.strip(), comp_std.strip(), comp_ver.strip()])
463
+ except Exception as sql_e:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
464
  conn_map.close()
465
+ return pd.DataFrame({"Error": [f"매핑 '{target_table_name}'을 여는 데 실패했습니다. 테이블 이름을 확인하세요: {str(sql_e)}"]})
466
+ conn_map.close()
467
 
468
+ cols_fw_lower = {c.lower(): c for c in df_fw.columns}
469
+
470
+ if 'base_type' in cols_fw_lower and 'comp_type' in cols_fw_lower:
471
+ b_col = cols_fw_lower['base_type']
472
+ c_col = cols_fw_lower['comp_type']
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
473
 
474
+ df_fw[b_col] = df_fw[b_col].fillna('Main').astype(str).str.strip().str.upper()
475
+ df_fw[c_col] = df_fw[c_col].fillna('Main').astype(str).str.strip().str.upper()
476
+ df_fw = df_fw[(df_fw[b_col] == type_b.strip().upper()) & (df_fw[c_col] == type_c.strip().upper())]
 
 
477
 
 
 
 
478
  if not df_rv.empty:
479
+ df_rv[b_col] = df_rv[b_col].fillna('Main').astype(str).str.strip().str.upper()
480
+ df_rv[c_col] = df_rv[c_col].fillna('Main').astype(str).str.strip().str.upper()
481
+ df_rv = df_rv[(df_rv[c_col] == type_b.strip().upper()) & (df_rv[b_col] == type_c.strip().upper())]
482
 
483
+ b_sec = cols_fw_lower.get('base_section', 'Base_section')
484
+ c_sec = cols_fw_lower.get('comp_section', 'Comp_section')
485
+
486
+ if b_sec not in df_fw.columns or c_sec not in df_fw.columns:
487
+ return pd.DataFrame({"Error": [f"'{target_table_name}' '{b_sec}' 또는 '{c_sec}' 열이 없습니다. 대소문자를 확인하세요."]})
488
+
489
+ df_fw = df_fw[[b_sec, c_sec]].rename(columns={b_sec: 'Base_section', c_sec: 'Comp_section'})
490
+ if not df_rv.empty:
491
+ df_rv = df_rv[[b_sec, c_sec]].rename(columns={b_sec: 'Comp_section', c_sec: 'Base_section'})
492
+ else:
493
+ df_rv = pd.DataFrame(columns=['Base_section', 'Comp_section'])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
494
 
495
+ df_mapping = pd.concat([df_fw, df_rv], ignore_index=True)
496
 
497
+ if not df_mapping.empty:
498
+ df_mapping['Base_section'] = df_mapping['Base_section'].astype(str).str.replace('\n', ',').str.split(',')
499
+ df_mapping['Comp_section'] = df_mapping['Comp_section'].astype(str).str.replace('\n', ',').str.split(',')
500
+ df_mapping = df_mapping.explode('Base_section').explode('Comp_section')
501
+
502
+ df_mapping['Base_section'] = df_mapping['Base_section'].apply(clean_key_val)
503
+ df_mapping['Comp_section'] = df_mapping['Comp_section'].apply(clean_key_val)
504
+
505
+ df_mapping = df_mapping[(df_mapping['Base_section'] != "") & (df_mapping['Comp_section'] != "")]
506
+ bridge = df_mapping.dropna().drop_duplicates()
507
+ else:
508
+ bridge = pd.DataFrame(columns=['Base_section', 'Comp_section'])
509
 
510
+ is_same_std = (base_std.strip().upper() == comp_std.strip().upper() and type_b.strip().upper() == type_c.strip().upper())
511
+ if bridge.empty and is_same_std:
512
+ all_keys = list(set(df_base['merge_key']).union(set(df_comp['merge_key'])))
513
+ bridge = pd.DataFrame({'Base_section': all_keys, 'Comp_section': all_keys})
514
 
515
  df_base = df_base.rename(columns=internal_rename_b)
 
516
  df_comp = df_comp.rename(columns=internal_rename_c)
517
 
 
 
518
  df_base['base_idx'], df_comp['comp_idx'] = range(len(df_base)), range(len(df_comp))
 
519
  merged = pd.merge(bridge, df_base, left_on='Base_section', right_on='merge_key', how='outer')
 
520
  merged = pd.merge(merged, df_comp, left_on='Comp_section', right_on='merge_key', how='outer')
 
521
 
 
522
  merged['base_idx'] = merged['base_idx'].fillna(float('inf'))
 
523
  merged['comp_idx'] = merged['comp_idx'].fillna(float('inf'))
 
524
  merged = merged.sort_values(['base_idx', 'comp_idx'])
525
 
 
 
526
  result_rows = []
 
527
  for _, row in merged.iterrows():
 
528
  row_dict = {}
 
529
  has_b = not pd.isna(row.get('base_idx')) and row.get('base_idx') != float('inf')
 
530
  has_c = not pd.isna(row.get('comp_idx')) and row.get('comp_idx') != float('inf')
 
531
 
 
532
  for c in internal_rename_b.values(): row_dict[c] = str(row[c]) if has_b and not pd.isna(row[c]) else ""
 
533
  for c in internal_rename_c.values(): row_dict[c] = str(row[c]) if has_c and not pd.isna(row[c]) else ""
 
534
 
 
535
  if mapped_only:
 
536
  b_has_val = any(str(row_dict[c]).strip() not in ["", "nan", "None", "&nbsp;"] for c in internal_rename_b.values())
 
537
  c_has_val = any(str(row_dict[c]).strip() not in ["", "nan", "None", "&nbsp;"] for c in internal_rename_c.values())
 
538
  if not (b_has_val and c_has_val):
 
539
  continue
 
540
 
 
541
  if has_b and has_c:
 
542
  for orig_col in internal_rename_b.keys():
 
543
  if ('description' in orig_col.lower() or '내용' in orig_col) and internal_rename_c.get(orig_col) in row_dict:
 
544
  b_v, c_v = row_dict[internal_rename_b[orig_col]], row_dict[internal_rename_c[orig_col]]
 
545
  if b_v and c_v and "<img" not in b_v and "<img" not in c_v and b_v != c_v:
 
546
  row_dict[internal_rename_b[orig_col]], row_dict[internal_rename_c[orig_col]] = generate_html_diff(b_v, c_v)
 
547
  result_rows.append(row_dict)
548
 
 
 
549
  final_df = combine_code_desc(pd.DataFrame(result_rows))
550
 
 
 
551
  if final_df.empty:
 
552
  return pd.DataFrame({"Info": ["💡 조건에 맞는 데이터가 없습니다."]})
553
 
 
 
554
  if diff_only:
 
555
  mask = final_df.astype(str).apply(lambda col: col.str.contains('color:#ff4d4f|color:#2ecc71', case=False, regex=True)).any(axis=1)
 
556
  final_df = final_df[mask]
 
557
  if final_df.empty:
 
558
  return pd.DataFrame({"Info": ["💡 선택하신 조건 간에 변경된 내용이 없습니다. (100% 동일)"]})
 
559
 
 
560
  final_rename_map = {}
 
561
  for col in final_df.columns:
 
562
  if col.endswith("_INTERNAL_BASE"):
 
563
  final_rename_map[col] = f"{col.replace('_INTERNAL_BASE', '')}_{base_ver}"
 
564
  elif col.endswith("_INTERNAL_COMP"):
 
565
  final_rename_map[col] = f"{col.replace('_INTERNAL_COMP', '')}_{comp_ver}"
 
566
 
 
567
  final_df = final_df.rename(columns=final_rename_map)
568
 
 
 
569
  b_cols_final = [c for c in final_df.columns if c.endswith(f"_{base_ver}")]
 
570
  c_cols_final = [c for c in final_df.columns if c.endswith(f"_{comp_ver}")]
571
 
 
 
572
  final_df = apply_visual_merge(final_df, b_cols_final)
 
573
  final_df = apply_visual_merge(final_df, c_cols_final)
574
 
 
 
575
  return final_df
576
 
 
 
577
  return pd.DataFrame({"Info": ["조건을 선택하세요."]})
 
578
  except Exception as e:
 
579
  error_msg = str(e)
 
580
  if "database is locked" in error_msg.lower():
 
581
  return pd.DataFrame({"Error": ["🚨 DB가 잠겨있습니다! 켜놓으신 'DB Browser' 프로그램을 완전히 종료한 뒤 다시 조회해 주세요."]})
 
582
  return pd.DataFrame({"Error": [f"시스템 오류 발생: {error_msg}"]})
583
 
 
 
584
  # ==========================================
 
585
  # 4. UI Layout & Event Binding
 
586
  # ==========================================
 
587
  with gr.Blocks() as demo:
 
588
  gr.Markdown("# 📜 Regulation Viewer")
589
 
 
 
590
  with gr.Row():
 
591
  with gr.Accordion("📌 기준 법규", open=True):
 
592
  with gr.Column():
 
593
  base_standard = gr.Dropdown(label="Standard")
 
594
  base_version = gr.Dropdown(label="Version")
 
595
  base_status = gr.Textbox(label="Status", interactive=False, lines=1)
 
596
 
 
597
  with gr.Row(elem_classes="reset-row"):
 
598
  base_category = gr.Dropdown(label="Category", scale=4)
 
599
  base_reset_btn = gr.Button("↺ 초기화", scale=1, elem_classes="reset-btn")
 
600
 
 
601
  with gr.Accordion("🔄 비교 법규", open=False):
 
602
  with gr.Column():
 
603
  comp_standard = gr.Dropdown(label="Standard", choices=[])
 
604
  comp_version = gr.Dropdown(label="Version", choices=[])
 
605
  comp_status = gr.Textbox(label="Status", interactive=False, lines=1)
 
606
 
 
607
  with gr.Row(elem_classes="reset-row"):
 
608
  comp_category = gr.Dropdown(label="Category", choices=[], scale=4)
 
609
  comp_reset_btn = gr.Button("↺ 초기화", scale=1, elem_classes="reset-btn")
610
 
 
 
611
  with gr.Row(elem_id="search_row"):
 
612
  search_btn = gr.Button("🔍 조회", variant="primary", scale=10)
 
613
  mapped_only_cb = gr.Checkbox(label="🔗 매핑된 항목만 보기", value=False, elem_id="mapped_cb_item", container=False, scale=1)
 
614
  diff_filter_cb = gr.Checkbox(label="💡 변경된 내용만 보기", value=False, elem_id="diff_cb_item", container=False, scale=1)
615
 
 
 
616
  output_df = gr.Dataframe(wrap=True, interactive=False, datatype="html", max_height=800)
617
 
 
 
618
  demo.load(fn=load_initial_standards, inputs=None, outputs=base_standard)
619
 
 
 
620
  base_standard.change(fn=update_version_dropdown, inputs=[base_standard], outputs=[base_version])
 
621
  base_version.change(fn=update_base_category_dropdown, inputs=[base_standard, base_version], outputs=[base_category, base_status])
622
 
 
 
623
  base_version.change(fn=update_comp_standard_dropdown, inputs=[base_standard, base_version], outputs=[comp_standard])
 
624
  comp_standard.change(fn=update_comp_version_dropdown, inputs=[base_standard, base_version, comp_standard], outputs=[comp_version])
625
 
 
 
626
  comp_change_triggers = [base_standard, base_version, base_category, comp_standard, comp_version]
 
627
 
 
628
  base_category.change(fn=update_comp_category_dropdown, inputs=comp_change_triggers, outputs=[comp_category, comp_status])
 
629
  comp_version.change(fn=update_comp_category_dropdown, inputs=comp_change_triggers, outputs=[comp_category, comp_status])
630
 
 
 
631
  search_btn.click(
 
632
  fn=execute_unified_search,
 
633
  inputs=[base_standard, base_version, base_category, comp_standard, comp_version, comp_category, mapped_only_cb, diff_filter_cb],
 
634
  outputs=[output_df]
 
635
  )
 
636
 
 
637
  mapped_only_cb.change(
 
638
  fn=execute_unified_search,
 
639
  inputs=[base_standard, base_version, base_category, comp_standard, comp_version, comp_category, mapped_only_cb, diff_filter_cb],
 
640
  outputs=[output_df]
 
641
  )
642
 
 
 
643
  diff_filter_cb.change(
 
644
  fn=execute_unified_search,
 
645
  inputs=[base_standard, base_version, base_category, comp_standard, comp_version, comp_category, mapped_only_cb, diff_filter_cb],
 
646
  outputs=[output_df]
 
647
  )
648
 
 
 
649
  base_reset_btn.click(fn=reset_base_selections, inputs=None, outputs=[base_standard, base_version, base_category, base_status])
 
650
  comp_reset_btn.click(fn=reset_comp_selections, inputs=None, outputs=[comp_standard, comp_version, comp_category, comp_status])
651
 
 
 
652
  # ==========================================
 
653
  # 5. Application Styling (CSS)
 
654
  # ==========================================
 
655
  css = """
 
656
  .reset-row {
 
657
  align-items: flex-end !important;
 
658
  margin-bottom: 5px !important;
 
659
  }
 
660
  .reset-btn {
 
661
  margin-bottom: 10px !important;
 
662
  }
 
663
  table {
 
664
  table-layout: auto !important;
 
665
  width: max-content !important;
 
666
  min-width: 100% !important;
 
667
  }
 
668
  th, td {
 
669
  min-width: 150px;
 
670
  }
 
671
  table:has(th:nth-last-child(2):first-child),
 
672
  table:has(th:nth-last-child(3):first-child),
 
673
  table:has(th:nth-last-child(4):first-child),
 
674
  table:has(th:nth-last-child(5):first-child),
 
675
  table:has(th:nth-last-child(6):first-child) {
 
676
  table-layout: fixed !important;
 
677
  width: 100% !important;
 
678
  }
 
679
  table th:nth-last-child(2):first-child, table td:nth-last-child(2):first-child { width: 15% !important; min-width: 0 !important; }
 
680
  table th:nth-last-child(1), table td:nth-last-child(1) { width: 85% !important; min-width: 0 !important; }
681
 
 
 
682
  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; }
 
683
  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; }
 
684
  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; }
685
 
 
 
686
  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; }
 
687
  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; }
 
688
  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; }
 
689
  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; }
690
 
 
 
691
  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; }
 
692
  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; }
 
693
  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; }
 
694
  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; }
 
695
  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; }
696
 
 
 
697
  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; }
 
698
  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; }
 
699
  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; }
 
700
  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; }
 
701
  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; }
 
702
  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; }
703
 
 
 
704
  thead th {
 
705
  font-size: 18px !important;
 
706
  position: sticky;
 
707
  top: 0;
 
708
  background: white;
 
709
  z-index: 10;
 
710
  }
 
711
  .dataframe {
 
712
  max-height: none !important;
 
713
  overflow-y: visible !important;
 
714
  overflow-x: auto !important;
 
715
  display: block;
 
716
  }
 
717
  .dataframe > div {
 
718
  max-height: none !important;
 
719
  overflow: visible !important;
 
720
  }
 
721
  td {
 
722
  font-size: 18px !important;
 
723
  white-space: pre-wrap !important;
 
724
  word-break: keep-all !important;
 
725
  line-height: 1.6;
 
726
  padding: 10px;
 
727
  vertical-align: top !important;
 
728
  text-align: left !important;
 
729
  }
 
730
  td img {
 
731
  display: block;
 
732
  max-width: none !important;
 
733
  }
 
734
  #search_row {
 
735
  align-items: center !important;
 
736
  margin-bottom: 5px !important;
 
737
  }
 
738
  #diff_cb_item, #mapped_cb_item {
 
739
  margin-top: 0 !important;
 
740
  padding-left: 15px !important;
 
741
  width: max-content !important;
 
742
  min-width: max-content !important;
 
743
  flex-grow: 0 !important;
 
744
  }
 
745
  """
746
 
 
 
747
  if __name__ == "__main__":
 
748
  demo.launch(
 
749
  theme=gr.themes.Soft(),
 
750
  share=True,
 
751
  css=css
 
752
  )