import gradio as gr import sqlite3 import pandas as pd import os import re import base64 import difflib import traceback # ========================================== # 1. Environment Setup & Data Helpers # ========================================== UPLOAD_DIR = "uploaded_dbs" if not os.path.exists(UPLOAD_DIR): os.makedirs(UPLOAD_DIR) db_registry = [] def convert_blob_to_html_img(blob_data): if blob_data is None or pd.isna(blob_data): return "" try: if isinstance(blob_data, (bytes, bytearray)): encoded = base64.b64encode(blob_data).decode('utf-8') return f''' ''' return str(blob_data) except Exception: return str(blob_data) def decode_sqlite_text(x): try: return x.decode('utf-8') except UnicodeDecodeError: return x def fetch_available_standards(): global db_registry db_registry = [] for file_name in os.listdir(UPLOAD_DIR): if file_name.endswith(".db"): name = file_name.replace(".db", "") parts = name.split("_") if len(parts) >= 2: db_registry.append({ "path": os.path.join(UPLOAD_DIR, file_name), "standard": parts[0], "version": parts[1] }) return sorted(list(set([d["standard"] for d in db_registry]))) # ========================================== # 2. UI Component Handlers # ========================================== def load_initial_standards(): return gr.Dropdown(choices=fetch_available_standards()) def update_version_dropdown(standard): if not standard: return gr.Dropdown(choices=[]) versions = sorted(set(d["version"] for d in db_registry if d["standard"] == standard)) return gr.Dropdown(choices=versions) # ⬇️ [수정] Version이 선택되면 Category와 함께 Status 값도 같이 리턴합니다. def update_base_category_dropdown(standard, version): if not standard or not version: return gr.update(choices=[], value=None, interactive=False), gr.update(value="") choices = ["ALL"] status_value = "" # Status 기본값 db_path = os.path.join(UPLOAD_DIR, f"{standard}_{version}.db") if not os.path.exists(db_path): return gr.update(choices=choices), gr.update(value="") try: conn = sqlite3.connect(db_path) tables = pd.read_sql("SELECT name FROM sqlite_master WHERE type='table';", conn)['name'].tolist() valid_tables = [t for t in tables if t.lower() not in ['mapping_registry', 'mapping_table', 'table_config', 'sqlite_sequence']] main_table = f"{standard}_{version}" if main_table not in valid_tables: main_table = valid_tables[0] if valid_tables else None if main_table: # 💡 [핵심 추가] DB에서 Status 값을 읽어옵니다. try: cols_check = pd.read_sql(f"PRAGMA table_info([{main_table}])", conn)['name'].tolist() if "Status" in cols_check or "status" in cols_check: status_df = pd.read_sql(f"SELECT Status FROM [{main_table}] WHERE Status IS NOT NULL AND Status != '' LIMIT 1", conn) if not status_df.empty: status_value = str(status_df.iloc[0]['Status']) except Exception: pass cols = pd.read_sql(f"PRAGMA table_info([{main_table}])", conn)['name'].tolist() lower_cols = [c.lower() for c in cols] if 'chapter' in lower_cols and 'category' in lower_cols: ch_col = cols[lower_cols.index('chapter')] ca_col = cols[lower_cols.index('category')] df = pd.read_sql(f"SELECT DISTINCT [{ch_col}], [{ca_col}] FROM [{main_table}]", conn) for _, row in df.iterrows(): ch = str(row[ch_col]).strip() ca = str(row[ca_col]).strip() if ch and ca and ch.lower() not in ['none', 'nan'] and ca.lower() not in ['none', 'nan']: choices.append(f"{ch}.{ca}") pattern = re.compile(f"^{standard}[_\\s-]*{version}[_\\s-]*", re.IGNORECASE) for t in valid_tables: if t == main_table: continue short_name = pattern.sub("", t).strip(" _") if short_name and short_name not in choices: choices.append(short_name) elif t not in choices: choices.append(t) conn.close() except Exception: import traceback traceback.print_exc() return gr.update(choices=choices, value=None, interactive=True), gr.update(value=status_value) def update_comp_standard_dropdown(base_std, base_ver): if not base_std or not base_ver: return gr.Dropdown(choices=[], value=None, interactive=False) try: conn = sqlite3.connect(os.path.join(UPLOAD_DIR, "mapping.db")) query = "SELECT DISTINCT TRIM(Comp_std) AS Comp_std FROM Mapping_registry WHERE TRIM(Base_std)=TRIM(?) AND TRIM(Base_ver)=TRIM(?)" df = pd.read_sql(query, conn, params=[base_std, base_ver]) conn.close() mapped_stds = sorted(df['Comp_std'].dropna().unique().tolist()) if not df.empty else [] return gr.update(choices=mapped_stds, value=None, interactive=bool(mapped_stds)) except Exception: return gr.update(choices=[], value=None, interactive=False) def update_comp_version_dropdown(base_std, base_ver, comp_std): if not all([base_std, base_ver, comp_std]): return gr.update(choices=[], value=None, interactive=False) try: conn = sqlite3.connect(os.path.join(UPLOAD_DIR, "mapping.db")) 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(?)" df = pd.read_sql(query, conn, params=[base_std, base_ver, comp_std]) conn.close() mapped_vers = sorted(df['Comp_ver'].dropna().unique().tolist()) if not df.empty else [] return gr.update(choices=mapped_vers, value=None, interactive=bool(mapped_vers)) except Exception: return gr.update(choices=[], value=None, interactive=False) # ⬇️ [수정] 비교 법규에서도 Category 갱신 시 Status를 읽어옵니다. def update_comp_category_dropdown(base_std, base_ver, base_cat, comp_std, comp_ver): if not all([base_std, base_ver, base_cat, comp_std, comp_ver]): return gr.update(choices=[], value=None, interactive=False), gr.update(value="") base_type = "Main" if not base_cat or base_cat == "ALL" or "." in base_cat else base_cat status_value = "" try: # Status 읽기 comp_db_path = os.path.join(UPLOAD_DIR, f"{comp_std}_{comp_ver}.db") if os.path.exists(comp_db_path): c_conn = sqlite3.connect(comp_db_path) c_tables = pd.read_sql("SELECT name FROM sqlite_master WHERE type='table';", c_conn)['name'].tolist() c_valid_tables = [t for t in c_tables if t.lower() not in ['mapping_registry', 'mapping_table', 'table_config', 'sqlite_sequence']] c_main_table = f"{comp_std}_{comp_ver}" if c_main_table not in c_valid_tables: c_main_table = c_valid_tables[0] if c_valid_tables else None if c_main_table: try: cols_check = pd.read_sql(f"PRAGMA table_info([{c_main_table}])", c_conn)['name'].tolist() if "Status" in cols_check or "status" in cols_check: status_df = pd.read_sql(f"SELECT Status FROM [{c_main_table}] WHERE Status IS NOT NULL AND Status != '' LIMIT 1", c_conn) if not status_df.empty: status_value = str(status_df.iloc[0]['Status']) except Exception: pass conn = sqlite3.connect(os.path.join(UPLOAD_DIR, "mapping.db")) query = """ SELECT DISTINCT TRIM(Comp_Type) AS Comp_Type FROM Mapping_registry 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(?) """ df = pd.read_sql(query, conn, params=[base_std, base_ver, base_type, comp_std, comp_ver]) conn.close() allowed_types = df['Comp_Type'].dropna().tolist() if not allowed_types: return gr.update(choices=[], value=None), gr.update(value=status_value) final_choices = [] if "Main" in allowed_types: final_choices.append("ALL") if os.path.exists(comp_db_path): # c_conn is already closed above, reopen if needed c_conn = sqlite3.connect(comp_db_path) if c_main_table: cols = pd.read_sql(f"PRAGMA table_info([{c_main_table}])", c_conn)['name'].tolist() lower_cols = [c.lower() for c in cols] if 'chapter' in lower_cols and 'category' in lower_cols: ch_col = cols[lower_cols.index('chapter')] ca_col = cols[lower_cols.index('category')] c_df = pd.read_sql(f"SELECT DISTINCT [{ch_col}], [{ca_col}] FROM [{c_main_table}]", c_conn) for _, row in c_df.iterrows(): ch = str(row[ch_col]).strip() ca = str(row[ca_col]).strip() if ch and ca and ch.lower() not in ['none', 'nan'] and ca.lower() not in ['none', 'nan']: final_choices.append(f"{ch}.{ca}") c_conn.close() for t in allowed_types: if t != "Main": final_choices.append(t) return gr.update(choices=final_choices, value=final_choices[0] if final_choices else None, interactive=True), gr.update(value=status_value) except Exception as e: import traceback traceback.print_exc() return gr.update(choices=[], value=None), gr.update(value="") def reset_base_selections(): # Status까지 4개를 리셋 return gr.update(value=None), gr.update(choices=[], value=None), gr.update(choices=[], value=None), gr.update(value="") def reset_comp_selections(): # Status까지 4개를 리셋 return gr.update(value=None), gr.update(choices=[], value=None), gr.update(choices=[], value=None), gr.update(value="") # ========================================== # 3. Core Logic & Data Processing # ========================================== def generate_html_diff(base_text, comp_text): base_text = "" if pd.isna(base_text) else str(base_text) comp_text = "" if pd.isna(comp_text) else str(comp_text) b_words = base_text.split() c_words = comp_text.split() diff_generator = list(difflib.ndiff(b_words, c_words)) b_result, c_result = [], [] i = 0 while i < len(diff_generator): code = diff_generator[i][0] word = diff_generator[i][2:] if code == ' ': b_result.append(word) c_result.append(word) elif code == '-' and i+1 < len(diff_generator) and diff_generator[i+1][0] == '+': new_word = diff_generator[i+1][2:] b_result.append(f"{word}") c_result.append(f"{new_word}") i += 1 elif code == '-': b_result.append(f"{word}") elif code == '+': c_result.append(f"{word}") i += 1 return " ".join(b_result), " ".join(c_result) def fetch_database_records(standard, version, selection, table_type): if not all([standard, version, selection]): return pd.DataFrame({"Info": ["선택 필요"]}), [] try: db_path = os.path.join(UPLOAD_DIR, f"{standard}_{version}.db") if not os.path.exists(db_path): return pd.DataFrame({"Error": [f"파일을 찾을 수 없습니다: {db_path}"]}), [] conn = sqlite3.connect(db_path) conn.text_factory = decode_sqlite_text tables = pd.read_sql("SELECT name FROM sqlite_master WHERE type='table';", conn)['name'].tolist() valid_tables = [t for t in tables if t.lower() not in ['mapping_registry', 'mapping_table', 'table_config', 'sqlite_sequence']] main_table = f"{standard}_{version}" if main_table not in valid_tables: main_table = valid_tables[0] if valid_tables else None target_table = None pattern = re.compile(f"^{standard}[_\\s-]*{version}[_\\s-]*", re.IGNORECASE) for t in valid_tables: if t == selection or pattern.sub("", t).strip(" _") == selection: target_table = t break if target_table and target_table != main_table: df = pd.read_sql(f"SELECT * FROM [{target_table}]", conn) else: if not main_table: return pd.DataFrame({"Error": ["데이터베이스 내에서 메인 테이블을 찾을 수 없습니다."]}), [] cursor = conn.cursor() cursor.execute(f"PRAGMA table_info([{main_table}])") cols = [c[1] for c in cursor.fetchall()] lower_cols = [c.lower().strip() for c in cols] real_ch, real_cat = None, None for c, lc in zip(cols, lower_cols): if lc == "chapter": real_ch = c elif lc == "category": real_cat = c if selection == "ALL": cursor.execute(f"SELECT * FROM [{main_table}]") elif "." in selection and real_ch and real_cat: ch, ca = selection.split(".", 1) query = f"SELECT * FROM [{main_table}] WHERE REPLACE(TRIM(CAST([{real_ch}] AS TEXT)), ' ', '') = ? AND REPLACE(TRIM(CAST([{real_cat}] AS TEXT)), ' ', '') = ?" cursor.execute(query, (ch.replace(" ", ""), ca.replace(" ", ""))) else: cursor.execute(f"SELECT * FROM [{main_table}]") df = pd.DataFrame(cursor.fetchall(), columns=cols) df = df.drop(columns=[c for c in df.columns if c.lower() == "version"], errors="ignore") # ========================================================= # 💡 [스마트 스캔 로직] DB 컬럼을 보고 맞는 Main 설정을 알아서 찾습니다. # ========================================================= conn_map = sqlite3.connect(os.path.join(UPLOAD_DIR, "mapping.db")) config_df = pd.read_sql("SELECT Anchor_Column, Display_Columns FROM Table_Config WHERE Table_Type=?", conn_map, params=[table_type]) conn_map.close() anchor_col_config = "Section" display_setting = "Description" if not config_df.empty: df_cols_lower = [c.lower() for c in df.columns] for _, row in config_df.iterrows(): # DB에 적힌 Anchor 열이 실제 데이터프레임에 존재하는지 확인 first_anchor = str(row['Anchor_Column']).split(',')[0].strip().lower() if first_anchor in df_cols_lower: anchor_col_config = str(row['Anchor_Column']) raw_disp = row['Display_Columns'] display_setting = str(raw_disp) if pd.notna(raw_disp) and str(raw_disp).strip() != "" else None break # ========================================================= anchor_cols_config = [x.strip() for x in anchor_col_config.split(',')] real_anchors = [] for ac in anchor_cols_config: real_ac = next((c for c in df.columns if c.lower() == ac.lower()), ac) real_anchors.append(real_ac) if display_setting: cols_to_show = [c.strip() for c in display_setting.split(',')] actual_cols_to_show = [c for c in df.columns if next((True for req in cols_to_show if c.lower() == req.lower()), False)] for ra in reversed(real_anchors): if ra in df.columns and ra not in actual_cols_to_show: actual_cols_to_show.insert(0, ra) if actual_cols_to_show: df = df[actual_cols_to_show] for col in df.columns: df[col] = df[col].apply(convert_blob_to_html_img) return df, real_anchors except Exception as e: import traceback traceback.print_exc() return pd.DataFrame({"Error": [str(e)]}), [] def execute_unified_search(base_std, base_ver, base_cat, comp_std, comp_ver, comp_cat, mapped_only, diff_only): try: def get_type_by_cat(cat): if not cat or cat == "ALL": return "Main" if "." in cat: return "Main" return cat type_b = get_type_by_cat(base_cat) type_c = get_type_by_cat(comp_cat) def apply_visual_merge(df, cols): if not df.empty and len(cols) > 1: is_dup = pd.Series([True] * len(df), index=df.index) for col in cols: if col in df.columns: curr = df[col].astype(str).str.strip() match = (curr == curr.shift(1)) & (~curr.isin(["", "nan", "None", " "])) is_dup = is_dup & match df.loc[is_dup, col] = " " return df def combine_code_desc(df): cols = list(df.columns) new_cols = [] processed = set() for col in cols: if col in processed: continue if "_Code" in col: desc_col = col.replace("_Code", "_Description") if desc_col in cols: new_col_name = col.replace("_Code", "") def combine_cells(row): c, d = str(row[col]).strip(), str(row[desc_col]).strip() if c in ["nan", "None", "", " "]: return d if d in ["nan", "None", "", " "]: return f"{c}" return f"{c}{d}" df[new_col_name] = df.apply(combine_cells, axis=1) new_cols.append(new_col_name) processed.update([col, desc_col]) else: new_cols.append(col) elif "_Description" in col: if col.replace("_Description", "_Code") not in cols: new_cols.append(col) else: new_cols.append(col) return df[new_cols] if base_std and base_ver and base_cat and (not comp_std or not comp_ver or not comp_cat): df, _ = fetch_database_records(base_std, base_ver, base_cat, type_b) if "Error" in df.columns: return df return apply_visual_merge(df, df.columns) if all([base_std, base_ver, base_cat, comp_std, comp_ver, comp_cat]): df_base, real_anchors_b = fetch_database_records(base_std, base_ver, base_cat, type_b) df_comp, real_anchors_c = fetch_database_records(comp_std, comp_ver, comp_cat, type_c) if "Error" in df_base.columns: return df_base if "Error" in df_comp.columns: return df_comp for ra in real_anchors_b: if ra not in df_base.columns: return pd.DataFrame({"Error": [f"기준 열(Anchor) '{ra}'이(가) 기준 데이터에 존재하지 않습니다. Table_Config를 확인하세요."]}) for ra in real_anchors_c: if ra not in df_comp.columns: return pd.DataFrame({"Error": [f"비교 열(Anchor) '{ra}'이(가) 비교 데이터에 존재하지 않습니다. Table_Config를 확인하세요."]}) def clean_key_val(v): s = str(v).strip() if s.endswith('.0') and s[:-2].isdigit(): s = s[:-2] return s.replace(" ", "") df_base['merge_key'] = df_base[real_anchors_b].apply(lambda row: '-'.join([clean_key_val(x) for x in row]), axis=1) df_comp['merge_key'] = df_comp[real_anchors_c].apply(lambda row: '-'.join([clean_key_val(x) for x in row]), axis=1) conn_map = sqlite3.connect(os.path.join(UPLOAD_DIR, "mapping.db")) q_fw = "SELECT * FROM Mapping_table WHERE TRIM(Base_std)=? AND TRIM(Base_ver)=? AND TRIM(Comp_std)=? AND TRIM(Comp_ver)=?" df_fw = pd.read_sql(q_fw, conn_map, params=[base_std.strip(), base_ver.strip(), comp_std.strip(), comp_ver.strip()]) q_rv = "SELECT * FROM Mapping_table WHERE TRIM(Comp_std)=? AND TRIM(Comp_ver)=? AND TRIM(Base_std)=? AND TRIM(Base_ver)=?" df_rv = pd.read_sql(q_rv, conn_map, params=[base_std.strip(), base_ver.strip(), comp_std.strip(), comp_ver.strip()]) conn_map.close() cols_fw_lower = {c.lower(): c for c in df_fw.columns} if 'base_type' in cols_fw_lower and 'comp_type' in cols_fw_lower: b_col = cols_fw_lower['base_type'] c_col = cols_fw_lower['comp_type'] df_fw[b_col] = df_fw[b_col].fillna('Main').astype(str).str.strip().str.upper() df_fw[c_col] = df_fw[c_col].fillna('Main').astype(str).str.strip().str.upper() df_fw = df_fw[(df_fw[b_col] == type_b.strip().upper()) & (df_fw[c_col] == type_c.strip().upper())] if not df_rv.empty: df_rv[b_col] = df_rv[b_col].fillna('Main').astype(str).str.strip().str.upper() df_rv[c_col] = df_rv[c_col].fillna('Main').astype(str).str.strip().str.upper() df_rv = df_rv[(df_rv[c_col] == type_b.strip().upper()) & (df_rv[b_col] == type_c.strip().upper())] b_sec = cols_fw_lower.get('base_section', 'Base_section') c_sec = cols_fw_lower.get('comp_section', 'Comp_section') df_fw = df_fw[[b_sec, c_sec]].rename(columns={b_sec: 'Base_section', c_sec: 'Comp_section'}) if not df_rv.empty: df_rv = df_rv[[b_sec, c_sec]].rename(columns={b_sec: 'Comp_section', c_sec: 'Base_section'}) else: df_rv = pd.DataFrame(columns=['Base_section', 'Comp_section']) df_mapping = pd.concat([df_fw, df_rv], ignore_index=True) if not df_mapping.empty: df_mapping['Base_section'] = df_mapping['Base_section'].astype(str).str.replace('\n', ',').str.split(',') df_mapping['Comp_section'] = df_mapping['Comp_section'].astype(str).str.replace('\n', ',').str.split(',') df_mapping = df_mapping.explode('Base_section').explode('Comp_section') df_mapping['Base_section'] = df_mapping['Base_section'].apply(clean_key_val) df_mapping['Comp_section'] = df_mapping['Comp_section'].apply(clean_key_val) df_mapping = df_mapping.dropna().drop_duplicates() else: df_mapping = pd.DataFrame(columns=['Base_section', 'Comp_section']) if base_std.strip().upper() == comp_std.strip().upper() and type_b.strip().upper() == type_c.strip().upper(): implicit = pd.DataFrame({'Base_section': list(set(df_base['merge_key']) & set(df_comp['merge_key'])), 'Comp_section': list(set(df_base['merge_key']) & set(df_comp['merge_key']))}) bridge = pd.concat([df_mapping, implicit], ignore_index=True).drop_duplicates() else: bridge = df_mapping.copy() rename_b = {c: f"{c}_{base_ver}" for c in df_base.columns if c != 'merge_key'} df_base = df_base.rename(columns=rename_b) rename_c = {c: f"{c}_{comp_ver}" for c in df_comp.columns if c != 'merge_key'} df_comp = df_comp.rename(columns=rename_c) df_base['base_idx'], df_comp['comp_idx'] = range(len(df_base)), range(len(df_comp)) merged = pd.merge(bridge, df_base, left_on='Base_section', right_on='merge_key', how='outer') merged = pd.merge(merged, df_comp, left_on='Comp_section', right_on='merge_key', how='outer') merged['base_idx'] = merged['base_idx'].fillna(float('inf')) merged['comp_idx'] = merged['comp_idx'].fillna(float('inf')) merged = merged.sort_values(['base_idx', 'comp_idx']) result_rows = [] for _, row in merged.iterrows(): row_dict = {} has_b = not pd.isna(row.get('base_idx')) and row.get('base_idx') != float('inf') has_c = not pd.isna(row.get('comp_idx')) and row.get('comp_idx') != float('inf') if mapped_only and not (has_b and has_c): continue for c in rename_b.values(): row_dict[c] = str(row[c]) if has_b and not pd.isna(row[c]) else "" for c in rename_c.values(): row_dict[c] = str(row[c]) if has_c and not pd.isna(row[c]) else "" if has_b and has_c: for orig_col in rename_b.keys(): if 'description' in orig_col.lower() and rename_c.get(orig_col) in row_dict: b_v, c_v = row_dict[rename_b[orig_col]], row_dict[rename_c[orig_col]] if b_v and c_v and " div { max-height: none !important; overflow: visible !important; } td { font-size: 18px !important; white-space: pre-wrap !important; word-break: keep-all !important; line-height: 1.6; padding: 10px; vertical-align: top !important; text-align: left !important; } td img { display: block; max-width: none !important; } #search_row { align-items: center !important; margin-bottom: 5px !important; } #diff_cb_item, #mapped_cb_item { margin-top: 0 !important; padding-left: 15px !important; width: max-content !important; min-width: max-content !important; flex-grow: 0 !important; } """ if __name__ == "__main__": demo.launch( theme=gr.themes.Soft(), share=True, css=css )