From 1ca5d5d7210e77e180ee9b30cca3bb58f643f50f Mon Sep 17 00:00:00 2001 From: ehddnr301 Date: Sat, 4 Oct 2025 15:05:44 +0900 Subject: [PATCH 01/11] =?UTF-8?q?=EC=84=A4=EC=A0=95=20=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=80=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=84=B0=20=EC=86=8C=EC=8A=A4=20=EC=84=B9=EC=85=98=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20&=20=EA=B8=B0=EC=A1=B4=20Cli=20=EC=A7=80=EC=9B=90?= =?UTF-8?q?=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cli/__init__.py | 34 ++--- interface/app_pages/settings.py | 35 +++++ .../app_pages/settings_sections/__init__.py | 9 ++ .../settings_sections/data_source_section.py | 144 ++++++++++++++++++ interface/core/config.py | 129 ++++++++++++++++ interface/pages_config.py | 1 + 6 files changed, 332 insertions(+), 20 deletions(-) create mode 100644 interface/app_pages/settings.py create mode 100644 interface/app_pages/settings_sections/__init__.py create mode 100644 interface/app_pages/settings_sections/data_source_section.py create mode 100644 interface/core/config.py diff --git a/cli/__init__.py b/cli/__init__.py index 52b7c87..9ebbbe9 100644 --- a/cli/__init__.py +++ b/cli/__init__.py @@ -1,7 +1,8 @@ """Lang2SQL CLI 프로그램입니다. -이 프로그램은 Datahub GMS 서버 URL을 설정하고, 필요 시 Streamlit 인터페이스를 실행합니다. +이 프로그램은 환경 초기화와 Streamlit 실행을 제공합니다. -명령어 예시: lang2sql --datahub_server http://localhost:8080 --run-streamlit +주의: --datahub_server 옵션은 더 이상 사용되지 않습니다(deprecated). +DataHub 설정은 UI의 설정 > 데이터 소스 탭에서 관리하세요. """ import click @@ -11,8 +12,7 @@ from cli.core.environment import initialize_environment from cli.core.streamlit_runner import run_streamlit_command from cli.utils.logger import configure_logging -from infra.monitoring.check_server import CheckServer -from llm_utils.tools import set_gms_server + from version import __version__ logger = configure_logging() @@ -24,12 +24,8 @@ @click.pass_context @click.option( "--datahub_server", - default="http://localhost:8080", - help=( - "Datahub GMS 서버의 URL을 설정합니다. " - "기본값은 'http://localhost:8080'이며, " - "운영 환경 또는 테스트 환경에 맞게 변경할 수 있습니다." - ), + default=None, + help=("[Deprecated] DataHub GMS URL. 이제는 UI 설정 > 데이터 소스에서 관리하세요."), ) @click.option( "--run-streamlit", @@ -76,7 +72,7 @@ ) def cli( ctx: click.Context, - datahub_server: str, + datahub_server: str | None, run_streamlit: bool, port: int, env_file_path: str | None = None, @@ -87,7 +83,6 @@ def cli( """Lang2SQL CLI 엔트리포인트. - 환경 변수 및 VectorDB 설정 초기화 - - GMS 서버 연결 및 헬스체크 - 필요 시 Streamlit 애플리케이션 실행 """ @@ -103,18 +98,17 @@ def cli( ctx.exit(1) logger.info( - "Initialization started: GMS server = %s, run_streamlit = %s, port = %d", - datahub_server, + "Initialization started: run_streamlit = %s, port = %d", run_streamlit, port, ) - if CheckServer.is_gms_server_healthy(url=datahub_server): - set_gms_server(datahub_server) - logger.info("GMS server URL successfully set: %s", datahub_server) - else: - logger.error("GMS server health check failed. URL: %s", datahub_server) - # ctx.exit(1) + # Deprecated 안내: CLI에서 DataHub 설정은 더 이상 처리하지 않습니다 + if datahub_server: + click.secho( + "[Deprecated] --datahub_server 옵션은 더 이상 사용되지 않습니다. 설정 > 데이터 소스 탭에서 설정하세요.", + fg="yellow", + ) if run_streamlit: run_streamlit_command(port) diff --git a/interface/app_pages/settings.py b/interface/app_pages/settings.py new file mode 100644 index 0000000..05edaaa --- /dev/null +++ b/interface/app_pages/settings.py @@ -0,0 +1,35 @@ +""" +Settings 페이지 – 섹션 기반 UI +""" + +import streamlit as st + +from interface.core.config import load_config +from interface.app_pages.settings_sections.data_source_section import ( + render_data_source_section, +) + + +st.title("⚙️ 설정") + +config = load_config() + +tabs = st.tabs(["데이터 소스", "LLM", "DB", "VectorDB", "Device"]) + +with tabs[0]: + render_data_source_section(config) + +with tabs[1]: + st.info("LLM 설정은 곧 제공됩니다.") + +with tabs[2]: + st.info("DB 연결 설정은 곧 제공됩니다.") + +with tabs[3]: + st.info("VectorDB 설정은 곧 제공됩니다.") + +with tabs[4]: + st.info("디바이스 설정은 곧 제공됩니다.") + +st.divider() +st.caption("민감 정보는 로그에 기록되지 않으며, 이 설정은 현재 세션에 우선 반영됩니다.") diff --git a/interface/app_pages/settings_sections/__init__.py b/interface/app_pages/settings_sections/__init__.py new file mode 100644 index 0000000..8b844eb --- /dev/null +++ b/interface/app_pages/settings_sections/__init__.py @@ -0,0 +1,9 @@ +# Namespace package for settings page sections + +__all__ = [ + "data_source_section", + "llm_section", + "db_section", + "vectordb_section", + "device_section", +] diff --git a/interface/app_pages/settings_sections/data_source_section.py b/interface/app_pages/settings_sections/data_source_section.py new file mode 100644 index 0000000..e001ecd --- /dev/null +++ b/interface/app_pages/settings_sections/data_source_section.py @@ -0,0 +1,144 @@ +import streamlit as st + +from infra.monitoring.check_server import CheckServer +from interface.core.config import ( + Config, + load_config, + update_datahub_server, + update_vectordb_settings, + update_data_source_mode, +) + + +def _render_status_banner(config: Config) -> None: + mode = config.data_source_mode + ready_msgs = [] + + if mode == "datahub": + is_ok = CheckServer.is_gms_server_healthy(url=config.datahub_server) + if is_ok: + st.success(f"데이터 소스 준비됨: DataHub ({config.datahub_server})") + else: + st.warning( + "DataHub 헬스 체크 실패. URL을 확인하거나 VectorDB로 전환하세요." + ) + elif mode == "vectordb": + if config.vectordb_type and ( + (config.vectordb_type == "faiss" and config.vectordb_location) + or (config.vectordb_type == "pgvector" and config.vectordb_location) + ): + st.success( + f"데이터 소스 준비됨: VectorDB ({config.vectordb_type}, {config.vectordb_location or '기본값'})" + ) + else: + st.warning("VectorDB 설정이 불완전합니다. 타입/위치를 확인하세요.") + else: + st.info( + "데이터 소스를 선택해주세요: DataHub 또는 VectorDB 중 하나는 필수입니다." + ) + + +def render_data_source_section(config: Config | None = None) -> None: + st.subheader("데이터 소스 (필수)") + + if config is None: + config = load_config() + + _render_status_banner(config) + + # 선택 스위치 + col = st.columns([1, 3])[0] + with col: + mode = st.radio( + "데이터 소스 선택", + options=["DataHub", "VectorDB"], + horizontal=True, + index=( + 0 if (config.data_source_mode or "datahub").lower() == "datahub" else 1 + ), + ) + selected = mode.lower() + update_data_source_mode(config, selected) + + st.divider() + + if selected == "datahub": + with st.container(border=True): + url = st.text_input( + "DataHub GMS 서버 URL", + value=config.datahub_server, + placeholder="http://localhost:8080", + help="예: http://localhost:8080", + ) + + cols = st.columns([1, 1, 2]) + with cols[0]: + if st.button("헬스 체크", key="ds_health"): + ok = CheckServer.is_gms_server_healthy(url=url) + if ok: + st.success("GMS 서버가 정상입니다.") + else: + st.error( + "GMS 서버 헬스 체크 실패. URL과 네트워크를 확인하세요." + ) + + with cols[1]: + if st.button("저장", key="ds_save"): + if not url: + st.warning("URL을 입력하세요.") + else: + ok = CheckServer.is_gms_server_healthy(url=url) + if not ok: + st.error("저장 실패: 헬스 체크가 통과되지 않았습니다.") + else: + try: + update_datahub_server(config, url) + st.success( + "저장되었습니다. 현재 세션에 즉시 반영됩니다." + ) + except Exception: + st.error( + "설정 적용 중 오류가 발생했습니다. 로그를 확인하세요." + ) + + else: # VectorDB + with st.container(border=True): + vtype = st.selectbox( + "VectorDB 타입", + options=["faiss", "pgvector"], + index=0 if (config.vectordb_type or "faiss") == "faiss" else 1, + ) + + placeholder_text = ( + "FAISS 디렉토리 경로 (예: ./dev/table_info_db)" + if vtype == "faiss" + else "pgvector 연결 문자열 (postgresql://user:pass@host:port/db)" + ) + + vloc = st.text_input( + "VectorDB 위치", + value=config.vectordb_location, + placeholder=placeholder_text, + help=placeholder_text, + ) + + cols = st.columns([1, 1, 2]) + with cols[0]: + if st.button("검증", key="vdb_validate"): + try: + update_vectordb_settings( + config, vectordb_type=vtype, vectordb_location=vloc + ) + st.success("VectorDB 설정이 유효합니다.") + except Exception as e: + st.error(f"검증 실패: {e}") + + with cols[1]: + if st.button("저장", key="vdb_save"): + try: + update_vectordb_settings( + config, vectordb_type=vtype, vectordb_location=vloc + ) + st.success("저장되었습니다. 현재 세션에 즉시 반영됩니다.") + except Exception as e: + st.error(f"저장 실패: {e}") diff --git a/interface/core/config.py b/interface/core/config.py new file mode 100644 index 0000000..a7f6063 --- /dev/null +++ b/interface/core/config.py @@ -0,0 +1,129 @@ +from dataclasses import dataclass +import os +from pathlib import Path + +try: + import streamlit as st # type: ignore +except Exception: # pragma: no cover - streamlit may not be present in non-UI contexts + st = None # type: ignore + +from llm_utils.tools import set_gms_server + + +DEFAULT_DATAHUB_SERVER = "http://localhost:8080" +DEFAULT_VECTORDB_TYPE = os.getenv("VECTORDB_TYPE", "faiss").lower() +DEFAULT_VECTORDB_LOCATION = os.getenv("VECTORDB_LOCATION", "") + + +@dataclass +class Config: + datahub_server: str = DEFAULT_DATAHUB_SERVER + vectordb_type: str = DEFAULT_VECTORDB_TYPE + vectordb_location: str = DEFAULT_VECTORDB_LOCATION + data_source_mode: str | None = None # "datahub" | "vectordb" | None + + +def _get_session_value(key: str) -> str | None: + if st is None: + return None + try: + if key in st.session_state and st.session_state[key]: + return str(st.session_state[key]) + except Exception: + return None + return None + + +def load_config() -> Config: + """Load configuration with priority: session_state > environment > defaults.""" + datahub = _get_session_value("datahub_server") or os.getenv( + "DATAHUB_SERVER", DEFAULT_DATAHUB_SERVER + ) + mode = _get_session_value("data_source_mode") + + vectordb_type = _get_session_value("vectordb_type") or os.getenv( + "VECTORDB_TYPE", DEFAULT_VECTORDB_TYPE + ) + vectordb_location = _get_session_value("vectordb_location") or os.getenv( + "VECTORDB_LOCATION", DEFAULT_VECTORDB_LOCATION + ) + + return Config( + datahub_server=datahub, + vectordb_type=vectordb_type.lower() if vectordb_type else DEFAULT_VECTORDB_TYPE, + vectordb_location=vectordb_location, + data_source_mode=mode, + ) + + +def update_datahub_server(config: Config, new_url: str) -> None: + """Update DataHub server URL across runtime config, env-aware clients, and session.""" + if not new_url: + return + config.datahub_server = new_url + + # Propagate to underlying tooling/clients + try: + set_gms_server(new_url) + except Exception: + # Fail-soft: UI should surface errors from callers if needed + pass + + # Reflect into session state for immediate UI reuse + if st is not None: + try: + st.session_state["datahub_server"] = new_url + except Exception: + pass + + +def update_data_source_mode(config: Config, mode: str | None) -> None: + """Persist user's data source selection (datahub | vectordb).""" + config.data_source_mode = mode + if st is not None: + try: + st.session_state["data_source_mode"] = mode + except Exception: + pass + + +def update_vectordb_settings( + config: Config, *, vectordb_type: str, vectordb_location: str | None +) -> None: + """Validate and update VectorDB settings into env and session. + + Basic validation rules follow CLI's behavior: + - vectordb_type must be 'faiss' or 'pgvector' + - if type == 'faiss' and location provided: must be an existing directory + - if type == 'pgvector' and location provided: must start with 'postgresql://' + """ + vtype = (vectordb_type or "").lower() + if vtype not in ("faiss", "pgvector"): + raise ValueError(f"지원하지 않는 VectorDB 타입: {vectordb_type}") + + vloc = vectordb_location or "" + if vloc: + if vtype == "faiss": + path = Path(vloc) + if not path.exists() or not path.is_dir(): + raise ValueError(f"유효하지 않은 FAISS 디렉토리 경로: {vloc}") + elif vtype == "pgvector": + if not vloc.startswith("postgresql://"): + raise ValueError("pgvector URL은 'postgresql://'로 시작해야 합니다") + + # Persist to runtime config + config.vectordb_type = vtype + config.vectordb_location = vloc + + # Reflect to process env for downstream modules + os.environ["VECTORDB_TYPE"] = vtype + if vloc: + os.environ["VECTORDB_LOCATION"] = vloc + + # Reflect to session state for UI + if st is not None: + try: + st.session_state["vectordb_type"] = vtype + st.session_state["vectordb_location"] = vloc + except Exception: + pass diff --git a/interface/pages_config.py b/interface/pages_config.py index ad09110..0c78353 100644 --- a/interface/pages_config.py +++ b/interface/pages_config.py @@ -16,4 +16,5 @@ st.Page("app_pages/home.py", title="🏠 홈"), st.Page("app_pages/lang2sql.py", title="🔍 Lang2SQL"), st.Page("app_pages/graph_builder.py", title="📊 그래프 빌더"), + st.Page("app_pages/settings.py", title="⚙️ 설정"), ] From dd24db61531f2a324febcd94eab6c971a7b56ed4 Mon Sep 17 00:00:00 2001 From: ehddnr301 Date: Sat, 4 Oct 2025 17:42:55 +0900 Subject: [PATCH 02/11] =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EC=86=8C?= =?UTF-8?q?=EC=8A=A4=20=EC=84=A4=EC=A0=95=EC=9D=84=20Lang2SQL=20=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=EC=97=90=EC=84=9C=20=EC=84=A0=ED=83=9D?= =?UTF-8?q?=ED=95=B4=20=EC=82=AC=EC=9A=A9=EA=B0=80=EB=8A=A5=ED=95=98?= =?UTF-8?q?=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/data_source_selector.py | 85 ++++++ interface/app_pages/lang2sql.py | 8 + interface/app_pages/settings.py | 5 +- .../settings_sections/data_source_section.py | 281 ++++++++++++++---- interface/core/config.py | 228 +++++++++++++- 5 files changed, 548 insertions(+), 59 deletions(-) create mode 100644 interface/app_pages/components/data_source_selector.py diff --git a/interface/app_pages/components/data_source_selector.py b/interface/app_pages/components/data_source_selector.py new file mode 100644 index 0000000..8aff30e --- /dev/null +++ b/interface/app_pages/components/data_source_selector.py @@ -0,0 +1,85 @@ +import streamlit as st + +from interface.core.config import ( + load_config, + get_data_sources_registry, + update_datahub_server, + update_vectordb_settings, + update_data_source_mode, +) + + +def render_sidebar_data_source_selector(config=None) -> None: + if config is None: + config = load_config() + + registry = get_data_sources_registry() + + st.sidebar.markdown("### 데이터 소스") + enable_data_source = st.sidebar.checkbox( + "데이터 소스 적용", value=True, key="enable_data_source" + ) + if not enable_data_source: + return + + mode_index = 0 if (config.data_source_mode or "datahub").lower() == "datahub" else 1 + selected_mode = st.sidebar.radio( + "소스 종류", options=["DataHub", "VectorDB"], index=mode_index, horizontal=True + ) + + if selected_mode == "DataHub": + datahub_names = [s.name for s in registry.datahub] + if not datahub_names: + st.sidebar.warning( + "등록된 DataHub가 없습니다. 설정 > 데이터 소스에서 추가하세요." + ) + return + dh_name = st.sidebar.selectbox( + "DataHub 인스턴스", options=datahub_names, key="sidebar_dh_select" + ) + if st.sidebar.button("소스 적용", key="sidebar_apply_dh"): + selected = next((s for s in registry.datahub if s.name == dh_name), None) + if selected is None: + st.sidebar.error("선택한 DataHub를 찾을 수 없습니다.") + return + try: + update_datahub_server(config, selected.url) + # DataHub 선택 시, FAISS 경로가 정의되어 있으면 기본 VectorDB 로케이션으로도 반영 + if selected.faiss_path: + try: + update_vectordb_settings( + config, + vectordb_type="faiss", + vectordb_location=selected.faiss_path, + ) + except Exception as e: + st.sidebar.warning(f"FAISS 경로 적용 경고: {e}") + update_data_source_mode(config, "datahub") + st.sidebar.success(f"DataHub 적용됨: {selected.name}") + except Exception as e: + st.sidebar.error(f"적용 실패: {e}") + else: + vdb_names = [s.name for s in registry.vectordb] + if not vdb_names: + st.sidebar.warning( + "등록된 VectorDB가 없습니다. 설정 > 데이터 소스에서 추가하세요." + ) + return + vdb_name = st.sidebar.selectbox( + "VectorDB 인스턴스", options=vdb_names, key="sidebar_vdb_select" + ) + if st.sidebar.button("소스 적용", key="sidebar_apply_vdb"): + selected = next((s for s in registry.vectordb if s.name == vdb_name), None) + if selected is None: + st.sidebar.error("선택한 VectorDB를 찾을 수 없습니다.") + return + try: + update_vectordb_settings( + config, + vectordb_type=selected.type, + vectordb_location=selected.location, + ) + update_data_source_mode(config, "vectordb") + st.sidebar.success(f"VectorDB 적용됨: {selected.name}") + except Exception as e: + st.sidebar.error(f"적용 실패: {e}") diff --git a/interface/app_pages/lang2sql.py b/interface/app_pages/lang2sql.py index b24b362..0b37b13 100644 --- a/interface/app_pages/lang2sql.py +++ b/interface/app_pages/lang2sql.py @@ -20,6 +20,10 @@ from interface.core.lang2sql_runner import run_lang2sql from interface.core.result_renderer import display_result from interface.core.session_utils import init_graph +from interface.core.config import load_config +from interface.app_pages.components.data_source_selector import ( + render_sidebar_data_source_selector, +) TITLE = "Lang2SQL" DEFAULT_QUERY = "고객 데이터를 기반으로 유니크한 유저 수를 카운트하는 쿼리" @@ -37,6 +41,10 @@ st.title(TITLE) +config = load_config() + +render_sidebar_data_source_selector(config) + st.sidebar.markdown("### 워크플로우 선택") use_enriched = st.sidebar.checkbox( "프로파일 추출 & 컨텍스트 보강 워크플로우 사용", value=False diff --git a/interface/app_pages/settings.py b/interface/app_pages/settings.py index 05edaaa..03e3a8e 100644 --- a/interface/app_pages/settings.py +++ b/interface/app_pages/settings.py @@ -14,7 +14,7 @@ config = load_config() -tabs = st.tabs(["데이터 소스", "LLM", "DB", "VectorDB", "Device"]) +tabs = st.tabs(["데이터 소스", "LLM", "DB", "Device"]) with tabs[0]: render_data_source_section(config) @@ -26,9 +26,6 @@ st.info("DB 연결 설정은 곧 제공됩니다.") with tabs[3]: - st.info("VectorDB 설정은 곧 제공됩니다.") - -with tabs[4]: st.info("디바이스 설정은 곧 제공됩니다.") st.divider() diff --git a/interface/app_pages/settings_sections/data_source_section.py b/interface/app_pages/settings_sections/data_source_section.py index e001ecd..2f63881 100644 --- a/interface/app_pages/settings_sections/data_source_section.py +++ b/interface/app_pages/settings_sections/data_source_section.py @@ -1,13 +1,18 @@ import streamlit as st - -from infra.monitoring.check_server import CheckServer from interface.core.config import ( Config, load_config, - update_datahub_server, update_vectordb_settings, update_data_source_mode, + get_data_sources_registry, + add_datahub_source, + update_datahub_source, + delete_datahub_source, + add_vectordb_source, + update_vectordb_source, + delete_vectordb_source, ) +from infra.monitoring.check_server import CheckServer def _render_status_banner(config: Config) -> None: @@ -15,13 +20,15 @@ def _render_status_banner(config: Config) -> None: ready_msgs = [] if mode == "datahub": - is_ok = CheckServer.is_gms_server_healthy(url=config.datahub_server) - if is_ok: + last_health = st.session_state.get("datahub_last_health") + if last_health is True: st.success(f"데이터 소스 준비됨: DataHub ({config.datahub_server})") - else: + elif last_health is False: st.warning( "DataHub 헬스 체크 실패. URL을 확인하거나 VectorDB로 전환하세요." ) + else: + st.info("DataHub 상태 미검증 – 헬스 체크 버튼으로 확인하세요.") elif mode == "vectordb": if config.vectordb_type and ( (config.vectordb_type == "faiss" and config.vectordb_location) @@ -62,83 +69,253 @@ def render_data_source_section(config: Config | None = None) -> None: st.divider() + registry = get_data_sources_registry() + if selected == "datahub": with st.container(border=True): - url = st.text_input( - "DataHub GMS 서버 URL", - value=config.datahub_server, - placeholder="http://localhost:8080", - help="예: http://localhost:8080", + st.write("등록된 DataHub") + for source in list(registry.datahub): + cols = st.columns([2, 4, 2, 1, 1]) + with cols[0]: + st.text(source.name) + with cols[1]: + st.text(source.url) + with cols[2]: + note_val = source.note or "" + st.caption(note_val) + with cols[3]: + if st.button("편집", key=f"edit_dh_{source.name}"): + st.session_state["edit_dh_name"] = source.name + with cols[4]: + if st.button("삭제", type="secondary", key=f"del_dh_{source.name}"): + delete_datahub_source(name=source.name) + st.rerun() + + # 편집 폼 + edit_dh = st.session_state.get("edit_dh_name") + if edit_dh: + st.divider() + st.write(f"DataHub 편집: {edit_dh}") + existing = next( + (s for s in registry.datahub if s.name == edit_dh), None + ) + if existing: + new_url = st.text_input( + "URL", value=existing.url, key="dh_edit_url" + ) + new_faiss = st.text_input( + "FAISS 저장 경로(선택)", + value=existing.faiss_path or "", + key="dh_edit_faiss", + ) + new_note = st.text_input( + "메모", value=existing.note or "", key="dh_edit_note" + ) + cols = st.columns([1, 1, 2]) + with cols[0]: + if st.button("헬스 체크", key="dh_edit_health"): + ok = CheckServer.is_gms_server_healthy(url=new_url) + st.session_state["datahub_last_health"] = bool(ok) + if ok: + st.success("GMS 서버가 정상입니다.") + else: + st.error( + "GMS 서버 헬스 체크 실패. URL과 네트워크를 확인하세요." + ) + with cols[1]: + if st.button("저장", key="dh_edit_save"): + try: + update_datahub_source( + name=edit_dh, + url=new_url, + faiss_path=(new_faiss or None), + note=(new_note or None), + ) + st.success("저장되었습니다.") + st.session_state.pop("edit_dh_name", None) + st.rerun() + except Exception as e: + st.error(f"저장 실패: {e}") + with cols[2]: + if st.button("취소", key="dh_edit_cancel"): + st.session_state.pop("edit_dh_name", None) + st.rerun() + + st.divider() + st.write("DataHub 추가") + dh_name = st.text_input("이름", key="dh_name") + dh_url = st.text_input( + "URL", key="dh_url", placeholder="http://localhost:8080" ) + dh_faiss = st.text_input( + "FAISS 저장 경로(선택)", + key="dh_faiss", + placeholder="예: ./dev/table_info_db", + ) + dh_note = st.text_input("메모", key="dh_note", placeholder="선택") cols = st.columns([1, 1, 2]) with cols[0]: - if st.button("헬스 체크", key="ds_health"): - ok = CheckServer.is_gms_server_healthy(url=url) + if st.button("헬스 체크", key="dh_health_new"): + ok = CheckServer.is_gms_server_healthy(url=dh_url) + st.session_state["datahub_last_health"] = bool(ok) if ok: st.success("GMS 서버가 정상입니다.") else: st.error( "GMS 서버 헬스 체크 실패. URL과 네트워크를 확인하세요." ) - with cols[1]: - if st.button("저장", key="ds_save"): - if not url: - st.warning("URL을 입력하세요.") - else: - ok = CheckServer.is_gms_server_healthy(url=url) - if not ok: - st.error("저장 실패: 헬스 체크가 통과되지 않았습니다.") + if st.button("추가", key="dh_add"): + try: + if not dh_name or not dh_url: + st.warning("이름과 URL을 입력하세요.") else: + add_datahub_source( + name=dh_name, + url=dh_url, + faiss_path=(dh_faiss or None), + note=dh_note or None, + ) + st.success("추가되었습니다.") + st.rerun() + except Exception as e: + st.error(f"추가 실패: {e}") + + else: # VectorDB + with st.container(border=True): + st.write("등록된 VectorDB") + for source in list(registry.vectordb): + cols = st.columns([2, 2, 4, 2, 1, 1]) + with cols[0]: + st.text(source.name) + with cols[1]: + st.text(source.type) + with cols[2]: + st.text(source.location) + with cols[3]: + st.caption(source.collection_prefix or "-") + with cols[4]: + if st.button("편집", key=f"edit_vdb_{source.name}"): + st.session_state["edit_vdb_name"] = source.name + with cols[5]: + if st.button( + "삭제", type="secondary", key=f"del_vdb_{source.name}" + ): + delete_vectordb_source(name=source.name) + st.rerun() + + # 편집 폼 + edit_vdb = st.session_state.get("edit_vdb_name") + if edit_vdb: + st.divider() + st.write(f"VectorDB 편집: {edit_vdb}") + existing = next( + (s for s in registry.vectordb if s.name == edit_vdb), None + ) + if existing: + new_type = st.selectbox( + "타입", + options=["faiss", "pgvector"], + index=(0 if existing.type == "faiss" else 1), + key="vdb_edit_type", + ) + new_loc_placeholder = ( + "FAISS 디렉토리 경로 (예: ./dev/table_info_db)" + if new_type == "faiss" + else "pgvector 연결 문자열 (postgresql://user:pass@host:port/db)" + ) + new_location = st.text_input( + "위치", + value=existing.location, + key="vdb_edit_location", + placeholder=new_loc_placeholder, + ) + new_prefix = st.text_input( + "컬렉션 접두사(선택)", + value=existing.collection_prefix or "", + key="vdb_edit_prefix", + ) + new_note = st.text_input( + "메모(선택)", value=existing.note or "", key="vdb_edit_note" + ) + cols = st.columns([1, 1, 2]) + with cols[0]: + if st.button("검증", key="vdb_edit_validate"): try: - update_datahub_server(config, url) - st.success( - "저장되었습니다. 현재 세션에 즉시 반영됩니다." + update_vectordb_settings( + config, + vectordb_type=new_type, + vectordb_location=new_location, ) - except Exception: - st.error( - "설정 적용 중 오류가 발생했습니다. 로그를 확인하세요." + st.success("설정이 유효합니다.") + except Exception as e: + st.error(f"검증 실패: {e}") + with cols[1]: + if st.button("저장", key="vdb_edit_save"): + try: + update_vectordb_source( + name=edit_vdb, + vtype=new_type, + location=new_location, + collection_prefix=(new_prefix or None), + note=(new_note or None), ) + st.success("저장되었습니다.") + st.session_state.pop("edit_vdb_name", None) + st.rerun() + except Exception as e: + st.error(f"저장 실패: {e}") + with cols[2]: + if st.button("취소", key="vdb_edit_cancel"): + st.session_state.pop("edit_vdb_name", None) + st.rerun() - else: # VectorDB - with st.container(border=True): - vtype = st.selectbox( - "VectorDB 타입", - options=["faiss", "pgvector"], - index=0 if (config.vectordb_type or "faiss") == "faiss" else 1, + st.divider() + st.write("VectorDB 추가") + vdb_name = st.text_input("이름", key="vdb_name") + vdb_type = st.selectbox( + "타입", options=["faiss", "pgvector"], key="vdb_type" ) - - placeholder_text = ( + vdb_loc_placeholder = ( "FAISS 디렉토리 경로 (예: ./dev/table_info_db)" - if vtype == "faiss" + if vdb_type == "faiss" else "pgvector 연결 문자열 (postgresql://user:pass@host:port/db)" ) - - vloc = st.text_input( - "VectorDB 위치", - value=config.vectordb_location, - placeholder=placeholder_text, - help=placeholder_text, + vdb_location = st.text_input( + "위치", key="vdb_location", placeholder=vdb_loc_placeholder + ) + vdb_prefix = st.text_input( + "컬렉션 접두사(선택)", key="vdb_prefix", placeholder="예: app1_" ) + vdb_note = st.text_input("메모(선택)", key="vdb_note") cols = st.columns([1, 1, 2]) with cols[0]: - if st.button("검증", key="vdb_validate"): + if st.button("검증", key="vdb_validate_new"): try: update_vectordb_settings( - config, vectordb_type=vtype, vectordb_location=vloc + config, + vectordb_type=vdb_type, + vectordb_location=vdb_location, ) - st.success("VectorDB 설정이 유효합니다.") + st.success("설정이 유효합니다.") except Exception as e: st.error(f"검증 실패: {e}") - with cols[1]: - if st.button("저장", key="vdb_save"): + if st.button("추가", key="vdb_add"): try: - update_vectordb_settings( - config, vectordb_type=vtype, vectordb_location=vloc - ) - st.success("저장되었습니다. 현재 세션에 즉시 반영됩니다.") + if not vdb_name or not vdb_type or not vdb_location: + st.warning("이름/타입/위치를 입력하세요.") + else: + add_vectordb_source( + name=vdb_name, + vtype=vdb_type, + location=vdb_location, + collection_prefix=(vdb_prefix or None), + note=(vdb_note or None), + ) + st.success("추가되었습니다.") + st.rerun() except Exception as e: - st.error(f"저장 실패: {e}") + st.error(f"추가 실패: {e}") diff --git a/interface/core/config.py b/interface/core/config.py index a7f6063..297f80b 100644 --- a/interface/core/config.py +++ b/interface/core/config.py @@ -1,6 +1,8 @@ -from dataclasses import dataclass +from dataclasses import dataclass, field, asdict +from typing import List, Optional, Any, Dict import os from pathlib import Path +import json try: import streamlit as st # type: ignore @@ -23,6 +25,29 @@ class Config: data_source_mode: str | None = None # "datahub" | "vectordb" | None +@dataclass +class DataHubSource: + name: str + url: str + faiss_path: Optional[str] = None + note: Optional[str] = None + + +@dataclass +class VectorDBSource: + name: str + type: str # 'faiss' | 'pgvector' + location: str + collection_prefix: Optional[str] = None + note: Optional[str] = None + + +@dataclass +class DataSourcesRegistry: + datahub: List[DataHubSource] = field(default_factory=list) + vectordb: List[VectorDBSource] = field(default_factory=list) + + def _get_session_value(key: str) -> str | None: if st is None: return None @@ -77,6 +102,195 @@ def update_datahub_server(config: Config, new_url: str) -> None: pass +# ---- Registry helpers ---- + + +def get_data_sources_registry() -> DataSourcesRegistry: + if st is not None and "data_sources_registry" in st.session_state: + reg = st.session_state["data_sources_registry"] + return reg # stored as DataSourcesRegistry + # Try load from disk + try: + registry = load_registry_from_disk() + except Exception: + registry = DataSourcesRegistry() + if st is not None: + st.session_state["data_sources_registry"] = registry + return registry + + +def _save_registry(registry: DataSourcesRegistry) -> None: + if st is not None: + st.session_state["data_sources_registry"] = registry + try: + save_registry_to_disk(registry) + except Exception: + # fail-soft; UI will still have session copy + pass + + +# ---- Disk persistence for registry ---- + + +def _get_registry_file_path() -> Path: + # Allow override via env var, else default to ./config/data_sources.json + override = os.getenv("LANG2SQL_REGISTRY_PATH") + if override: + return Path(override).expanduser().resolve() + return Path(os.getcwd()) / "config" / "data_sources.json" + + +def _ensure_parent_dir(path: Path) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + + +def save_registry_to_disk(registry: DataSourcesRegistry) -> None: + path = _get_registry_file_path() + _ensure_parent_dir(path) + payload = asdict(registry) + with path.open("w", encoding="utf-8") as f: + json.dump(payload, f, ensure_ascii=False, indent=2) + + +def _parse_datahub_list(items: List[Dict[str, Any]]) -> List[DataHubSource]: + parsed: List[DataHubSource] = [] + for item in items or []: + name = str(item.get("name", "")).strip() + url = str(item.get("url", "")).strip() + faiss_path = item.get("faiss_path") + note = item.get("note") + if not name or not url: + continue + parsed.append( + DataHubSource(name=name, url=url, faiss_path=faiss_path, note=note) + ) + return parsed + + +def _parse_vectordb_list(items: List[Dict[str, Any]]) -> List[VectorDBSource]: + parsed: List[VectorDBSource] = [] + for item in items or []: + name = str(item.get("name", "")).strip() + vtype = str(item.get("type", "")).strip().lower() + location = str(item.get("location", "")).strip() + if not name or not vtype or not location: + continue + collection_prefix = item.get("collection_prefix") + note = item.get("note") + parsed.append( + VectorDBSource( + name=name, + type=vtype, + location=location, + collection_prefix=collection_prefix, + note=note, + ) + ) + return parsed + + +def load_registry_from_disk() -> DataSourcesRegistry: + path = _get_registry_file_path() + if not path.exists(): + return DataSourcesRegistry() + with path.open("r", encoding="utf-8") as f: + data: Dict[str, Any] = json.load(f) + return DataSourcesRegistry( + datahub=_parse_datahub_list(data.get("datahub", [])), + vectordb=_parse_vectordb_list(data.get("vectordb", [])), + ) + + +def add_datahub_source( + *, name: str, url: str, faiss_path: Optional[str] = None, note: Optional[str] = None +) -> None: + registry = get_data_sources_registry() + if any(s.name == name for s in registry.datahub): + raise ValueError(f"이미 존재하는 DataHub 이름입니다: {name}") + registry.datahub.append( + DataHubSource(name=name, url=url, faiss_path=faiss_path, note=note) + ) + _save_registry(registry) + + +def update_datahub_source( + *, name: str, url: str, faiss_path: Optional[str], note: Optional[str] +) -> None: + registry = get_data_sources_registry() + for idx, s in enumerate(registry.datahub): + if s.name == name: + registry.datahub[idx] = DataHubSource( + name=name, url=url, faiss_path=faiss_path, note=note + ) + _save_registry(registry) + return + raise ValueError(f"존재하지 않는 DataHub 이름입니다: {name}") + + +def delete_datahub_source(*, name: str) -> None: + registry = get_data_sources_registry() + registry.datahub = [s for s in registry.datahub if s.name != name] + _save_registry(registry) + + +def add_vectordb_source( + *, + name: str, + vtype: str, + location: str, + collection_prefix: Optional[str] = None, + note: Optional[str] = None, +) -> None: + vtype = (vtype or "").lower() + if vtype not in ("faiss", "pgvector"): + raise ValueError("VectorDB 타입은 'faiss' 또는 'pgvector'여야 합니다") + registry = get_data_sources_registry() + if any(s.name == name for s in registry.vectordb): + raise ValueError(f"이미 존재하는 VectorDB 이름입니다: {name}") + registry.vectordb.append( + VectorDBSource( + name=name, + type=vtype, + location=location, + collection_prefix=collection_prefix, + note=note, + ) + ) + _save_registry(registry) + + +def update_vectordb_source( + *, + name: str, + vtype: str, + location: str, + collection_prefix: Optional[str], + note: Optional[str], +) -> None: + vtype = (vtype or "").lower() + if vtype not in ("faiss", "pgvector"): + raise ValueError("VectorDB 타입은 'faiss' 또는 'pgvector'여야 합니다") + registry = get_data_sources_registry() + for idx, s in enumerate(registry.vectordb): + if s.name == name: + registry.vectordb[idx] = VectorDBSource( + name=name, + type=vtype, + location=location, + collection_prefix=collection_prefix, + note=note, + ) + _save_registry(registry) + return + raise ValueError(f"존재하지 않는 VectorDB 이름입니다: {name}") + + +def delete_vectordb_source(*, name: str) -> None: + registry = get_data_sources_registry() + registry.vectordb = [s for s in registry.vectordb if s.name != name] + _save_registry(registry) + + def update_data_source_mode(config: Config, mode: str | None) -> None: """Persist user's data source selection (datahub | vectordb).""" config.data_source_mode = mode @@ -105,8 +319,16 @@ def update_vectordb_settings( if vloc: if vtype == "faiss": path = Path(vloc) - if not path.exists() or not path.is_dir(): - raise ValueError(f"유효하지 않은 FAISS 디렉토리 경로: {vloc}") + # 신규 경로 허용: 존재하면 디렉토리인지 확인, 없으면 상위 디렉토리 생성 + if path.exists() and not path.is_dir(): + raise ValueError( + f"유효하지 않은 FAISS 디렉토리 경로(파일 경로임): {vloc}" + ) + if not path.exists(): + try: + path.mkdir(parents=True, exist_ok=True) + except Exception as e: + raise ValueError(f"FAISS 경로 생성 실패: {vloc} | {e}") elif vtype == "pgvector": if not vloc.startswith("postgresql://"): raise ValueError("pgvector URL은 'postgresql://'로 시작해야 합니다") From af2d7b624d88c5b395d1346bffa9aedbb7032a9c Mon Sep 17 00:00:00 2001 From: ehddnr301 Date: Sat, 4 Oct 2025 17:52:54 +0900 Subject: [PATCH 03/11] =?UTF-8?q?VectorDB=20=EC=84=A4=EC=A0=95=EC=9D=84=20?= =?UTF-8?q?CLI=EC=97=90=EC=84=9C=20=EC=A0=9C=EA=B1=B0=ED=95=98=EA=B3=A0=20?= =?UTF-8?q?UI=EC=97=90=EC=84=9C=20=EA=B4=80=EB=A6=AC=ED=95=98=EB=8F=84?= =?UTF-8?q?=EB=A1=9D=20=EB=B3=80=EA=B2=BD.=20=EA=B4=80=EB=A0=A8=EB=90=9C?= =?UTF-8?q?=20=EC=98=B5=EC=85=98=20=EB=B0=8F=20=EC=B4=88=EA=B8=B0=ED=99=94?= =?UTF-8?q?=20=ED=95=A8=EC=88=98=20=EC=88=98=EC=A0=95.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cli/__init__.py | 29 ++++++++++++++--------------- cli/core/environment.py | 11 +++-------- 2 files changed, 17 insertions(+), 23 deletions(-) diff --git a/cli/__init__.py b/cli/__init__.py index 9ebbbe9..a917ae9 100644 --- a/cli/__init__.py +++ b/cli/__init__.py @@ -57,18 +57,13 @@ ) @click.option( "--vectordb-type", - type=click.Choice(["faiss", "pgvector"]), - default="faiss", - help="사용할 벡터 데이터베이스 타입 (기본값: faiss)", + default=None, + help="[Deprecated] VectorDB 타입. 이제는 UI 설정 > 데이터 소스에서 관리하세요.", ) @click.option( "--vectordb-location", - help=( - "VectorDB 위치 설정\n" - "- FAISS: 디렉토리 경로 (예: ./my_vectordb)\n" - "- pgvector: 연결 문자열 (예: postgresql://user:pass@host:port/db)\n" - "기본값: FAISS는 './dev/table_info_db', pgvector는 환경변수 사용" - ), + default=None, + help="[Deprecated] VectorDB 위치. 이제는 UI 설정 > 데이터 소스에서 관리하세요.", ) def cli( ctx: click.Context, @@ -77,8 +72,8 @@ def cli( port: int, env_file_path: str | None = None, prompt_dir_path: str | None = None, - vectordb_type: str = "faiss", - vectordb_location: str = None, + vectordb_type: str | None = None, + vectordb_location: str | None = None, ) -> None: """Lang2SQL CLI 엔트리포인트. @@ -88,10 +83,7 @@ def cli( try: initialize_environment( - env_file_path=env_file_path, - prompt_dir_path=prompt_dir_path, - vectordb_type=vectordb_type, - vectordb_location=vectordb_location, + env_file_path=env_file_path, prompt_dir_path=prompt_dir_path ) except Exception: logger.error("Initialization failed.", exc_info=True) @@ -110,6 +102,13 @@ def cli( fg="yellow", ) + # Deprecated 안내: CLI에서 VectorDB 설정은 더 이상 처리하지 않습니다 + if vectordb_type or vectordb_location: + click.secho( + "[Deprecated] --vectordb-type/--vectordb-location 옵션은 더 이상 사용되지 않습니다. 설정 > 데이터 소스 탭에서 설정하세요.", + fg="yellow", + ) + if run_streamlit: run_streamlit_command(port) diff --git a/cli/core/environment.py b/cli/core/environment.py index 5d4317f..d2c957a 100644 --- a/cli/core/environment.py +++ b/cli/core/environment.py @@ -1,28 +1,23 @@ -"""환경 변수 및 VectorDB 초기화 모듈.""" +"""환경 변수 초기화 모듈 (VectorDB 설정은 UI에서 관리).""" from typing import Optional -from cli.utils.env_loader import load_env, set_prompt_dir, set_vectordb +from cli.utils.env_loader import load_env, set_prompt_dir def initialize_environment( *, env_file_path: Optional[str], prompt_dir_path: Optional[str], - vectordb_type: str, - vectordb_location: Optional[str], ) -> None: - """환경 변수와 VectorDB 설정을 초기화합니다. + """환경 변수를 초기화합니다. VectorDB 설정은 UI에서 관리합니다. Args: env_file_path (Optional[str]): 로드할 .env 파일 경로. None이면 기본값 사용. prompt_dir_path (Optional[str]): 프롬프트 템플릿 디렉토리 경로. None이면 설정하지 않음. - vectordb_type (str): VectorDB 타입 ("faiss" 또는 "pgvector"). - vectordb_location (Optional[str]): VectorDB 위치. None이면 기본값 사용. Raises: Exception: 초기화 과정에서 오류가 발생한 경우. """ load_env(env_file_path=env_file_path) set_prompt_dir(prompt_dir_path=prompt_dir_path) - set_vectordb(vectordb_type=vectordb_type, vectordb_location=vectordb_location) From b31de272c1780163cbebd1928caa271c7af2d96b Mon Sep 17 00:00:00 2001 From: ehddnr301 Date: Sat, 4 Oct 2025 18:25:56 +0900 Subject: [PATCH 04/11] =?UTF-8?q?LLM=20=EB=B0=8F=20Embeddings=20=EC=84=A0?= =?UTF-8?q?=ED=83=9D=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80:=20Lang2SQL?= =?UTF-8?q?=20=ED=8E=98=EC=9D=B4=EC=A7=80=EC=99=80=20=EC=84=A4=EC=A0=95=20?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=A7=80=EC=97=90=20LLM=20=EB=B0=8F=20Embedd?= =?UTF-8?q?ings=20=EC=84=A0=ED=83=9D=20=EC=BB=B4=ED=8F=AC=EB=84=8C?= =?UTF-8?q?=ED=8A=B8=20=ED=86=B5=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/embedding_selector.py | 35 +++ .../app_pages/components/llm_selector.py | 31 +++ interface/app_pages/lang2sql.py | 8 + interface/app_pages/settings.py | 3 +- .../settings_sections/llm_section.py | 218 ++++++++++++++++++ interface/core/config.py | 71 ++++++ 6 files changed, 365 insertions(+), 1 deletion(-) create mode 100644 interface/app_pages/components/embedding_selector.py create mode 100644 interface/app_pages/components/llm_selector.py create mode 100644 interface/app_pages/settings_sections/llm_section.py diff --git a/interface/app_pages/components/embedding_selector.py b/interface/app_pages/components/embedding_selector.py new file mode 100644 index 0000000..1aceaff --- /dev/null +++ b/interface/app_pages/components/embedding_selector.py @@ -0,0 +1,35 @@ +import os +import streamlit as st + +from interface.core.config import update_embedding_settings +from interface.app_pages.settings_sections.llm_section import LLM_PROVIDERS + + +def render_sidebar_embedding_selector() -> None: + st.sidebar.markdown("### Embeddings 선택") + + default_emb = ( + ( + st.session_state.get("EMBEDDING_PROVIDER") + or os.getenv("EMBEDDING_PROVIDER") + or "openai" + ) + ).lower() + try: + default_idx = LLM_PROVIDERS.index(default_emb) + except ValueError: + default_idx = 0 + + selected = st.sidebar.selectbox( + "Embeddings 공급자", + options=LLM_PROVIDERS, + index=default_idx, + key="sidebar_embedding_provider", + ) + + if selected != default_emb: + try: + update_embedding_settings(provider=selected, values={}) + st.sidebar.success(f"Embeddings 공급자가 '{selected}'로 변경되었습니다.") + except Exception as e: + st.sidebar.error(f"Embeddings 공급자 변경 실패: {e}") diff --git a/interface/app_pages/components/llm_selector.py b/interface/app_pages/components/llm_selector.py new file mode 100644 index 0000000..418bc4f --- /dev/null +++ b/interface/app_pages/components/llm_selector.py @@ -0,0 +1,31 @@ +import os +import streamlit as st + +from interface.core.config import update_llm_settings +from interface.app_pages.settings_sections.llm_section import LLM_PROVIDERS + + +def render_sidebar_llm_selector() -> None: + st.sidebar.markdown("### LLM 선택") + + default_llm = ( + (st.session_state.get("LLM_PROVIDER") or os.getenv("LLM_PROVIDER") or "openai") + ).lower() + try: + default_idx = LLM_PROVIDERS.index(default_llm) + except ValueError: + default_idx = 0 + + selected = st.sidebar.selectbox( + "LLM 공급자", + options=LLM_PROVIDERS, + index=default_idx, + key="sidebar_llm_provider", + ) + + if selected != default_llm: + try: + update_llm_settings(provider=selected, values={}) + st.sidebar.success(f"LLM 공급자가 '{selected}'로 변경되었습니다.") + except Exception as e: + st.sidebar.error(f"LLM 공급자 변경 실패: {e}") diff --git a/interface/app_pages/lang2sql.py b/interface/app_pages/lang2sql.py index 0b37b13..dd32983 100644 --- a/interface/app_pages/lang2sql.py +++ b/interface/app_pages/lang2sql.py @@ -24,6 +24,10 @@ from interface.app_pages.components.data_source_selector import ( render_sidebar_data_source_selector, ) +from interface.app_pages.components.llm_selector import render_sidebar_llm_selector +from interface.app_pages.components.embedding_selector import ( + render_sidebar_embedding_selector, +) TITLE = "Lang2SQL" DEFAULT_QUERY = "고객 데이터를 기반으로 유니크한 유저 수를 카운트하는 쿼리" @@ -44,6 +48,8 @@ config = load_config() render_sidebar_data_source_selector(config) +render_sidebar_llm_selector() +render_sidebar_embedding_selector() st.sidebar.markdown("### 워크플로우 선택") use_enriched = st.sidebar.checkbox( @@ -63,6 +69,8 @@ f"Lang2SQL이 성공적으로 새로고침되었습니다. ({GRAPH_TYPE} 워크플로우)" ) +## moved to component: render_sidebar_llm_selector() + user_query = st.text_area("쿼리를 입력하세요:", value=DEFAULT_QUERY) if "dialects" not in st.session_state: diff --git a/interface/app_pages/settings.py b/interface/app_pages/settings.py index 03e3a8e..38591d0 100644 --- a/interface/app_pages/settings.py +++ b/interface/app_pages/settings.py @@ -8,6 +8,7 @@ from interface.app_pages.settings_sections.data_source_section import ( render_data_source_section, ) +from interface.app_pages.settings_sections.llm_section import render_llm_section st.title("⚙️ 설정") @@ -20,7 +21,7 @@ render_data_source_section(config) with tabs[1]: - st.info("LLM 설정은 곧 제공됩니다.") + render_llm_section(config) with tabs[2]: st.info("DB 연결 설정은 곧 제공됩니다.") diff --git a/interface/app_pages/settings_sections/llm_section.py b/interface/app_pages/settings_sections/llm_section.py new file mode 100644 index 0000000..cbf116c --- /dev/null +++ b/interface/app_pages/settings_sections/llm_section.py @@ -0,0 +1,218 @@ +import os +import streamlit as st + +from interface.core.config import ( + update_llm_settings, + update_embedding_settings, + Config, + load_config, +) + + +LLM_PROVIDERS = [ + "openai", + "azure", + "bedrock", + "gemini", + "ollama", + "huggingface", +] + + +def _llm_fields(provider: str) -> list[tuple[str, str, bool]]: + """Return list of (label, env_key, is_secret) for LLM provider.""" + p = provider.lower() + if p == "openai": + return [ + ("Model", "OPEN_AI_LLM_MODEL", False), + ("API Key", "OPEN_AI_KEY", True), + ] + if p == "azure": + return [ + ("Endpoint", "AZURE_OPENAI_LLM_ENDPOINT", False), + ("Deployment(Model)", "AZURE_OPENAI_LLM_MODEL", False), + ("API Version", "AZURE_OPENAI_LLM_API_VERSION", False), + ("API Key", "AZURE_OPENAI_LLM_KEY", True), + ] + if p == "bedrock": + return [ + ("Model", "AWS_BEDROCK_LLM_MODEL", False), + ("Access Key ID", "AWS_BEDROCK_LLM_ACCESS_KEY_ID", True), + ("Secret Access Key", "AWS_BEDROCK_LLM_SECRET_ACCESS_KEY", True), + ("Region", "AWS_BEDROCK_LLM_REGION", False), + ] + if p == "gemini": + return [ + ("Model", "GEMINI_LLM_MODEL", False), + # ChatGoogleGenerativeAI uses GOOGLE_API_KEY at process level, but factory currently reads only model + ] + if p == "ollama": + return [ + ("Model", "OLLAMA_LLM_MODEL", False), + ("Base URL", "OLLAMA_LLM_BASE_URL", False), + ] + if p == "huggingface": + return [ + ("Endpoint URL", "HUGGING_FACE_LLM_ENDPOINT", False), + ("Repo ID", "HUGGING_FACE_LLM_REPO_ID", False), + ("Model", "HUGGING_FACE_LLM_MODEL", False), + ("API Token", "HUGGING_FACE_LLM_API_TOKEN", True), + ] + return [] + + +def _embedding_fields(provider: str) -> list[tuple[str, str, bool]]: + p = provider.lower() + if p == "openai": + return [ + ("Model", "OPEN_AI_EMBEDDING_MODEL", False), + ("API Key", "OPEN_AI_KEY", True), + ] + if p == "azure": + return [ + ("Endpoint", "AZURE_OPENAI_EMBEDDING_ENDPOINT", False), + ("Deployment(Model)", "AZURE_OPENAI_EMBEDDING_MODEL", False), + ("API Version", "AZURE_OPENAI_EMBEDDING_API_VERSION", False), + ("API Key", "AZURE_OPENAI_EMBEDDING_KEY", True), + ] + if p == "bedrock": + return [ + ("Model", "AWS_BEDROCK_EMBEDDING_MODEL", False), + ("Access Key ID", "AWS_BEDROCK_EMBEDDING_ACCESS_KEY_ID", True), + ("Secret Access Key", "AWS_BEDROCK_EMBEDDING_SECRET_ACCESS_KEY", True), + ("Region", "AWS_BEDROCK_EMBEDDING_REGION", False), + ] + if p == "gemini": + return [ + ("Model", "GEMINI_EMBEDDING_MODEL", False), + ("API Key", "GEMINI_EMBEDDING_KEY", True), + ] + if p == "ollama": + return [ + ("Model", "OLLAMA_EMBEDDING_MODEL", False), + ("Base URL", "OLLAMA_EMBEDDING_BASE_URL", False), + ] + if p == "huggingface": + return [ + ("Model", "HUGGING_FACE_EMBEDDING_MODEL", False), + ("Repo ID", "HUGGING_FACE_EMBEDDING_REPO_ID", False), + ("API Token", "HUGGING_FACE_EMBEDDING_API_TOKEN", True), + ] + return [] + + +def render_llm_section(config: Config | None = None) -> None: + st.subheader("LLM 설정") + + if config is None: + try: + config = load_config() + except Exception: + config = None # UI 일관성을 위한 옵셔널 처리 + + llm_col, emb_col = st.columns(2) + + with llm_col: + st.markdown("**Chat LLM**") + default_llm_provider = ( + ( + st.session_state.get("LLM_PROVIDER") + or os.getenv("LLM_PROVIDER") + or "openai" + ) + ).lower() + try: + default_llm_index = LLM_PROVIDERS.index(default_llm_provider) + except ValueError: + default_llm_index = 0 + provider = st.selectbox( + "공급자", + options=LLM_PROVIDERS, + index=default_llm_index, + key="llm_provider", + ) + fields = _llm_fields(provider) + values: dict[str, str | None] = {} + for label, env_key, is_secret in fields: + prefill = st.session_state.get(env_key) or os.getenv(env_key) or "" + if is_secret: + values[env_key] = st.text_input( + label, value=prefill, type="password", key=f"llm_{env_key}" + ) + else: + values[env_key] = st.text_input( + label, value=prefill, key=f"llm_{env_key}" + ) + + # 메시지 영역: 버튼 컬럼 밖(섹션 폭)으로 배치하여 좁은 폭에 눌려 깨지는 문제 방지 + llm_msg = st.empty() + + save_cols = st.columns([1, 1, 2]) + with save_cols[0]: + if st.button("저장", key="llm_save"): + try: + update_llm_settings(provider=provider, values=values) + llm_msg.success("LLM 설정이 저장되었습니다.") + except Exception as e: + llm_msg.error(f"저장 실패: {e}") + with save_cols[1]: + if st.button("검증", key="llm_validate"): + # 가벼운 검증: 필수 키 존재 여부만 확인 + try: + update_llm_settings(provider=provider, values=values) + llm_msg.success( + "형식 검증 완료. 실제 호출은 실행 경로에서 재검증됩니다." + ) + except Exception as e: + llm_msg.error(f"검증 실패: {e}") + + with emb_col: + st.markdown("**Embeddings**") + default_emb_provider = ( + ( + st.session_state.get("EMBEDDING_PROVIDER") + or os.getenv("EMBEDDING_PROVIDER") + or "openai" + ) + ).lower() + try: + default_emb_index = LLM_PROVIDERS.index(default_emb_provider) + except ValueError: + default_emb_index = 0 + e_provider = st.selectbox( + "공급자", + options=LLM_PROVIDERS, + index=default_emb_index, + key="embedding_provider", + ) + e_fields = _embedding_fields(e_provider) + e_values: dict[str, str | None] = {} + for label, env_key, is_secret in e_fields: + prefill = st.session_state.get(env_key) or os.getenv(env_key) or "" + if is_secret: + e_values[env_key] = st.text_input( + label, value=prefill, type="password", key=f"emb_{env_key}" + ) + else: + e_values[env_key] = st.text_input( + label, value=prefill, key=f"emb_{env_key}" + ) + + # 메시지 영역: 버튼 컬럼 밖(섹션 폭) + emb_msg = st.empty() + + e_cols = st.columns([1, 1, 2]) + with e_cols[0]: + if st.button("저장", key="emb_save"): + try: + update_embedding_settings(provider=e_provider, values=e_values) + emb_msg.success("Embeddings 설정이 저장되었습니다.") + except Exception as e: + emb_msg.error(f"저장 실패: {e}") + with e_cols[1]: + if st.button("검증", key="emb_validate"): + try: + update_embedding_settings(provider=e_provider, values=e_values) + emb_msg.success("형식 검증 완료.") + except Exception as e: + emb_msg.error(f"검증 실패: {e}") diff --git a/interface/core/config.py b/interface/core/config.py index 297f80b..df6d616 100644 --- a/interface/core/config.py +++ b/interface/core/config.py @@ -349,3 +349,74 @@ def update_vectordb_settings( st.session_state["vectordb_location"] = vloc except Exception: pass + + +# ---- LLM & Embeddings helpers ---- + + +def _put_env(key: str, value: str | None) -> None: + if value is None: + return + os.environ[key] = value + + +def _put_session(key: str, value: str | None) -> None: + if st is None: + return + try: + st.session_state[key] = value + except Exception: + pass + + +def update_llm_settings(*, provider: str, values: dict[str, str | None]) -> None: + """Update chat LLM settings from UI into process env and session. + + This function mirrors the environment-variable based configuration consumed by + llm_utils.llm.factory.get_llm(). Only sets provided keys; missing values are left as-is. + """ + provider_norm = (provider or "").lower() + if provider_norm not in { + "openai", + "azure", + "bedrock", + "gemini", + "ollama", + "huggingface", + }: + raise ValueError(f"지원하지 않는 LLM 공급자: {provider}") + + # Core selector + _put_env("LLM_PROVIDER", provider_norm) + _put_session("LLM_PROVIDER", provider_norm) + + # Provider-specific fields (keys exactly as factory expects) + for k, v in (values or {}).items(): + if v is not None: + _put_env(k, v) + _put_session(k, v) + + +def update_embedding_settings(*, provider: str, values: dict[str, str | None]) -> None: + """Update Embeddings settings from UI into process env and session. + + Mirrors env vars consumed by llm_utils.llm.factory.get_embeddings(). + """ + provider_norm = (provider or "").lower() + if provider_norm not in { + "openai", + "azure", + "bedrock", + "gemini", + "ollama", + "huggingface", + }: + raise ValueError(f"지원하지 않는 Embedding 공급자: {provider}") + + _put_env("EMBEDDING_PROVIDER", provider_norm) + _put_session("EMBEDDING_PROVIDER", provider_norm) + + for k, v in (values or {}).items(): + if v is not None: + _put_env(k, v) + _put_session(k, v) From a6e9cc16eb17d773f360991fe1b2ec9231fd456e Mon Sep 17 00:00:00 2001 From: ehddnr301 Date: Sat, 4 Oct 2025 19:56:42 +0900 Subject: [PATCH 05/11] =?UTF-8?q?LLM/Embeddings/DB=20=ED=94=84=EB=A1=9C?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC(=EC=8B=9C=ED=81=AC=EB=A6=BF=20=ED=8F=AC?= =?UTF-8?q?=ED=95=A8)=20JSON=20=EC=A0=80=EC=9E=A5=C2=B7=EC=82=AC=EC=9D=B4?= =?UTF-8?q?=EB=93=9C=EB=B0=94=20=EC=A0=81=EC=9A=A9=20+=20DB=20=EC=84=B9?= =?UTF-8?q?=EC=85=98=20CRUD=20=ED=99=95=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- interface/app_pages/components/db_selector.py | 48 ++ .../components/embedding_selector.py | 88 +++- .../app_pages/components/llm_selector.py | 82 +++- interface/app_pages/lang2sql.py | 2 + interface/app_pages/settings.py | 3 +- .../app_pages/settings_sections/db_section.py | 298 ++++++++++++ .../settings_sections/llm_section.py | 122 +++-- interface/core/config.py | 451 ++++++++++++++++++ 8 files changed, 1021 insertions(+), 73 deletions(-) create mode 100644 interface/app_pages/components/db_selector.py create mode 100644 interface/app_pages/settings_sections/db_section.py diff --git a/interface/app_pages/components/db_selector.py b/interface/app_pages/components/db_selector.py new file mode 100644 index 0000000..4f9bff0 --- /dev/null +++ b/interface/app_pages/components/db_selector.py @@ -0,0 +1,48 @@ +import os +import streamlit as st + +from interface.core.config import get_db_connections_registry, update_db_settings + + +def render_sidebar_db_selector() -> None: + st.sidebar.markdown("### DB 연결") + + registry = get_db_connections_registry() + names = [c.name for c in registry.connections] + if not names: + st.sidebar.warning("등록된 DB 프로파일이 없습니다. 설정 > DB에서 추가하세요.") + return + + # 기본 선택: 세션 또는 ENV의 DB_TYPE과 일치하는 첫 프로파일 + current_type = ( + st.session_state.get("DB_TYPE") or os.getenv("DB_TYPE") or "" + ).lower() + default_index = 0 + if current_type: + for idx, c in enumerate(registry.connections): + if c.type == current_type: + default_index = idx + break + + sel_name = st.sidebar.selectbox( + "프로파일", options=names, index=default_index, key="sidebar_db_profile" + ) + selected = next((c for c in registry.connections if c.name == sel_name), None) + if selected is None: + st.sidebar.error("선택한 프로파일을 찾을 수 없습니다.") + return + + if st.sidebar.button("적용", key="sidebar_apply_db"): + try: + values = { + "host": selected.host, + "port": selected.port, + "user": selected.user, + "password": selected.password, + "database": selected.database, + "extra": selected.extra, + } + update_db_settings(db_type=selected.type, values=values, secrets={}) + st.sidebar.success(f"DB 적용됨: {selected.name}") + except Exception as e: + st.sidebar.error(f"적용 실패: {e}") diff --git a/interface/app_pages/components/embedding_selector.py b/interface/app_pages/components/embedding_selector.py index 1aceaff..c01f50c 100644 --- a/interface/app_pages/components/embedding_selector.py +++ b/interface/app_pages/components/embedding_selector.py @@ -1,35 +1,81 @@ import os import streamlit as st -from interface.core.config import update_embedding_settings -from interface.app_pages.settings_sections.llm_section import LLM_PROVIDERS +from interface.core.config import ( + update_embedding_settings, + get_embedding_registry, +) def render_sidebar_embedding_selector() -> None: st.sidebar.markdown("### Embeddings 선택") - default_emb = ( - ( - st.session_state.get("EMBEDDING_PROVIDER") - or os.getenv("EMBEDDING_PROVIDER") - or "openai" + e_reg = get_embedding_registry() + if not e_reg.profiles: + st.sidebar.info( + "저장된 Embeddings 프로파일이 없습니다. 설정 > LLM에서 저장하세요." ) + # fallback: 간단 공급자 선택 유지 + default_emb = ( + ( + st.session_state.get("EMBEDDING_PROVIDER") + or os.getenv("EMBEDDING_PROVIDER") + or "openai" + ) + ).lower() + selected = st.sidebar.selectbox( + "Embeddings 공급자", + options=["openai", "azure", "bedrock", "gemini", "ollama", "huggingface"], + index=( + ["openai", "azure", "bedrock", "gemini", "ollama", "huggingface"].index( + default_emb + ) + if default_emb + in {"openai", "azure", "bedrock", "gemini", "ollama", "huggingface"} + else 0 + ), + key="sidebar_embedding_provider_fallback", + ) + if selected != default_emb: + try: + update_embedding_settings(provider=selected, values={}) + st.sidebar.success( + f"Embeddings 공급자가 '{selected}'로 변경되었습니다." + ) + except Exception as e: + st.sidebar.error(f"Embeddings 공급자 변경 실패: {e}") + return + + e_names = [p.name for p in e_reg.profiles] + current_emb_provider = ( + st.session_state.get("EMBEDDING_PROVIDER") + or os.getenv("EMBEDDING_PROVIDER") + or "" ).lower() - try: - default_idx = LLM_PROVIDERS.index(default_emb) - except ValueError: - default_idx = 0 - - selected = st.sidebar.selectbox( - "Embeddings 공급자", - options=LLM_PROVIDERS, - index=default_idx, - key="sidebar_embedding_provider", + e_default_index = 0 + if current_emb_provider: + for idx, p in enumerate(e_reg.profiles): + if p.provider == current_emb_provider: + e_default_index = idx + break + + e_sel_name = st.sidebar.selectbox( + "Embeddings 프로파일", + options=e_names, + index=e_default_index, + key="sidebar_embedding_profile", ) - if selected != default_emb: + e_selected = next((p for p in e_reg.profiles if p.name == e_sel_name), None) + if e_selected is None: + st.sidebar.error("선택한 Embeddings 프로파일을 찾을 수 없습니다.") + return + + if st.sidebar.button("Embeddings 적용", key="sidebar_apply_embedding_profile"): try: - update_embedding_settings(provider=selected, values={}) - st.sidebar.success(f"Embeddings 공급자가 '{selected}'로 변경되었습니다.") + update_embedding_settings( + provider=e_selected.provider, values=e_selected.fields + ) + st.sidebar.success(f"Embeddings 프로파일 적용됨: {e_selected.name}") except Exception as e: - st.sidebar.error(f"Embeddings 공급자 변경 실패: {e}") + st.sidebar.error(f"Embeddings 프로파일 적용 실패: {e}") diff --git a/interface/app_pages/components/llm_selector.py b/interface/app_pages/components/llm_selector.py index 418bc4f..b8260e0 100644 --- a/interface/app_pages/components/llm_selector.py +++ b/interface/app_pages/components/llm_selector.py @@ -1,31 +1,77 @@ import os import streamlit as st -from interface.core.config import update_llm_settings -from interface.app_pages.settings_sections.llm_section import LLM_PROVIDERS +from interface.core.config import ( + update_llm_settings, + get_llm_registry, +) def render_sidebar_llm_selector() -> None: st.sidebar.markdown("### LLM 선택") - default_llm = ( - (st.session_state.get("LLM_PROVIDER") or os.getenv("LLM_PROVIDER") or "openai") + reg = get_llm_registry() + if not reg.profiles: + st.sidebar.info( + "저장된 LLM 프로파일이 없습니다. 설정 > LLM에서 프로파일을 저장하세요." + ) + # 기존 방식 fallback + default_llm = ( + ( + st.session_state.get("LLM_PROVIDER") + or os.getenv("LLM_PROVIDER") + or "openai" + ) + ).lower() + selected_provider = st.sidebar.selectbox( + "LLM 공급자", + options=["openai", "azure", "bedrock", "gemini", "ollama", "huggingface"], + index=( + ["openai", "azure", "bedrock", "gemini", "ollama", "huggingface"].index( + default_llm + ) + if default_llm + in {"openai", "azure", "bedrock", "gemini", "ollama", "huggingface"} + else 0 + ), + key="sidebar_llm_provider_fallback", + ) + if selected_provider != default_llm: + try: + update_llm_settings(provider=selected_provider, values={}) + st.sidebar.success( + f"LLM 공급자가 '{selected_provider}'로 변경되었습니다." + ) + except Exception as e: + st.sidebar.error(f"LLM 공급자 변경 실패: {e}") + return + + names = [p.name for p in reg.profiles] + # 기본 선택: 세션의 LLM_PROVIDER와 같은 provider를 가진 첫 프로파일 + current_provider = ( + st.session_state.get("LLM_PROVIDER") or os.getenv("LLM_PROVIDER") or "" ).lower() - try: - default_idx = LLM_PROVIDERS.index(default_llm) - except ValueError: - default_idx = 0 - - selected = st.sidebar.selectbox( - "LLM 공급자", - options=LLM_PROVIDERS, - index=default_idx, - key="sidebar_llm_provider", + default_index = 0 + if current_provider: + for idx, p in enumerate(reg.profiles): + if p.provider == current_provider: + default_index = idx + break + + sel_name = st.sidebar.selectbox( + "LLM 프로파일", options=names, index=default_index, key="sidebar_llm_profile" ) + selected = next((p for p in reg.profiles if p.name == sel_name), None) + if selected is None: + st.sidebar.error("선택한 LLM 프로파일을 찾을 수 없습니다.") + return - if selected != default_llm: + if st.sidebar.button("적용", key="sidebar_apply_llm_profile"): try: - update_llm_settings(provider=selected, values={}) - st.sidebar.success(f"LLM 공급자가 '{selected}'로 변경되었습니다.") + # provider 설정 + 프로파일의 비민감 필드만 적용 + update_llm_settings(provider=selected.provider, values=selected.fields) + st.sidebar.success(f"LLM 프로파일 적용됨: {selected.name}") except Exception as e: - st.sidebar.error(f"LLM 공급자 변경 실패: {e}") + st.sidebar.error(f"LLM 프로파일 적용 실패: {e}") + + # Embeddings 관련 UI는 embedding_selector.py에서 처리 diff --git a/interface/app_pages/lang2sql.py b/interface/app_pages/lang2sql.py index dd32983..6252961 100644 --- a/interface/app_pages/lang2sql.py +++ b/interface/app_pages/lang2sql.py @@ -28,6 +28,7 @@ from interface.app_pages.components.embedding_selector import ( render_sidebar_embedding_selector, ) +from interface.app_pages.components.db_selector import render_sidebar_db_selector TITLE = "Lang2SQL" DEFAULT_QUERY = "고객 데이터를 기반으로 유니크한 유저 수를 카운트하는 쿼리" @@ -50,6 +51,7 @@ render_sidebar_data_source_selector(config) render_sidebar_llm_selector() render_sidebar_embedding_selector() +render_sidebar_db_selector() st.sidebar.markdown("### 워크플로우 선택") use_enriched = st.sidebar.checkbox( diff --git a/interface/app_pages/settings.py b/interface/app_pages/settings.py index 38591d0..b43b66a 100644 --- a/interface/app_pages/settings.py +++ b/interface/app_pages/settings.py @@ -9,6 +9,7 @@ render_data_source_section, ) from interface.app_pages.settings_sections.llm_section import render_llm_section +from interface.app_pages.settings_sections.db_section import render_db_section st.title("⚙️ 설정") @@ -24,7 +25,7 @@ render_llm_section(config) with tabs[2]: - st.info("DB 연결 설정은 곧 제공됩니다.") + render_db_section() with tabs[3]: st.info("디바이스 설정은 곧 제공됩니다.") diff --git a/interface/app_pages/settings_sections/db_section.py b/interface/app_pages/settings_sections/db_section.py new file mode 100644 index 0000000..db9da6b --- /dev/null +++ b/interface/app_pages/settings_sections/db_section.py @@ -0,0 +1,298 @@ +import os +import streamlit as st + +from interface.core.config import ( + get_db_connections_registry, + add_db_connection, + update_db_connection, + delete_db_connection, + update_db_settings, +) +from db_utils import get_db_connector, load_config_from_env + + +DB_TYPES = [ + "postgresql", + "mysql", + "mariadb", + "oracle", + "clickhouse", + "duckdb", + "sqlite", + "databricks", + "snowflake", + "trino", +] + + +def _non_secret_fields(db_type: str) -> list[tuple[str, str]]: + t = (db_type or "").lower() + # label, key (values dict) + if t in {"duckdb", "sqlite"}: + return [("Path", "path")] + base = [ + ("Host", "host"), + ("Port", "port"), + ("User", "user"), + ("Database", "database"), + ] + return base + + +def _extra_non_secret_fields(db_type: str) -> list[tuple[str, str]]: + t = (db_type or "").lower() + if t == "oracle": + return [("Service Name", "service_name")] + if t == "databricks": + return [ + ("HTTP Path", "http_path"), + ("Catalog(옵션)", "catalog"), + ("Schema(옵션)", "schema"), + ] + if t == "snowflake": + return [ + ("Account", "account"), + ("Warehouse(옵션)", "warehouse"), + ("Schema(옵션)", "schema"), + ] + if t == "trino": + return [ + ("HTTP Scheme(http/https)", "http_scheme"), + ("Catalog", "catalog"), + ("Schema", "schema"), + ] + return [] + + +def _secret_fields(db_type: str) -> list[tuple[str, str]]: + t = (db_type or "").lower() + if t in {"postgresql", "mysql", "mariadb", "oracle", "clickhouse", "trino"}: + return [("Password", "password")] + if t == "databricks": + return [("Access Token", "access_token")] + if t == "snowflake": + return [("Password", "password")] + # duckdb/sqlite는 비밀번호 필요 없음 + return [] + + +def _prefill_from_env(db_type: str, key: str) -> str: + # Normalize to PREFIX_KEY + prefix = (db_type or "").upper() + if key == "path": + # duckdb/sqlite: PATH 로 통일 저장 + return os.getenv(f"{prefix}_PATH", "") + return os.getenv(f"{prefix}_{key.upper()}", "") + + +def render_db_section() -> None: + st.subheader("DB 연결") + + registry = get_db_connections_registry() + + # 목록 렌더링 + with st.container(border=True): + st.write("등록된 DB 프로파일") + for profile in list(registry.connections): + cols = st.columns([2, 2, 3, 2, 1, 1]) + with cols[0]: + st.text(profile.name) + with cols[1]: + st.text(profile.type) + with cols[2]: + host_or_path = profile.host or (profile.extra or {}).get("path") or "-" + st.caption(host_or_path) + with cols[3]: + st.caption(profile.note or "") + with cols[4]: + if st.button("편집", key=f"edit_db_{profile.name}"): + st.session_state["edit_db_name"] = profile.name + with cols[5]: + if st.button("삭제", type="secondary", key=f"del_db_{profile.name}"): + delete_db_connection(name=profile.name) + st.rerun() + + # 편집 폼 + edit_name = st.session_state.get("edit_db_name") + if edit_name: + st.divider() + st.write(f"DB 프로파일 편집: {edit_name}") + existing = next((c for c in registry.connections if c.name == edit_name), None) + if existing: + new_type = st.selectbox( + "타입", + options=DB_TYPES, + index=max( + 0, DB_TYPES.index(existing.type) if existing.type in DB_TYPES else 0 + ), + key="db_edit_type", + ) + + values: dict[str, object] = {} + # 기본 필드 + for label, k in _non_secret_fields(new_type): + default_val = ( + existing.extra.get("path") + if k == "path" and existing.extra + else getattr(existing, k, "") + ) + v = st.text_input( + label, value=str(default_val or ""), key=f"db_edit_val_{k}" + ) + if v != "": + values[k] = v + + # 추가 필드(Non-secret) + extra_vals: dict[str, str] = {} + for label, k in _extra_non_secret_fields(new_type): + default_val = (existing.extra or {}).get(k, "") + v = st.text_input( + label, value=str(default_val or ""), key=f"db_edit_extra_{k}" + ) + if v != "": + extra_vals[k] = v + if extra_vals: + values["extra"] = extra_vals + + # 시크릿 필드 (JSON 저장 허용) + secrets: dict[str, str] = {} + for label, k in _secret_fields(new_type): + # 기존 저장값 우선: existing.password or existing.extra + default_secret = "" + if k == "password": + default_secret = getattr( + existing, "password", None + ) or _prefill_from_env(new_type, k) + else: + default_secret = (existing.extra or {}).get( + k, "" + ) or _prefill_from_env(new_type, k) + sv = st.text_input( + label, + value=str(default_secret or ""), + type="password", + key=f"db_edit_secret_{k}", + ) + if sv != "": + secrets[k] = sv + + cols = st.columns([1, 1, 2]) + with cols[0]: + if st.button("적용(세션)", key="db_edit_apply"): + try: + update_db_settings( + db_type=new_type, values=values, secrets=secrets + ) + st.success("환경/세션에 적용되었습니다.") + except Exception as e: + st.error(f"적용 실패: {e}") + with cols[1]: + if st.button("저장", key="db_edit_save"): + try: + update_db_connection( + name=edit_name, + db_type=new_type, + host=str(values.get("host") or "") or None, + port=( + int(values.get("port")) + if str(values.get("port") or "").isdigit() + else None + ), + user=str(values.get("user") or "") or None, + password=(secrets.get("password") or None), + database=str(values.get("database") or "") or None, + extra=values.get("extra"), + note=existing.note, + ) + st.success("저장되었습니다.") + st.session_state.pop("edit_db_name", None) + st.rerun() + except Exception as e: + st.error(f"저장 실패: {e}") + with cols[2]: + if st.button("연결 테스트", key="db_edit_test"): + try: + # 먼저 적용하여 env에 반영 + update_db_settings( + db_type=new_type, values=values, secrets=secrets + ) + connector = get_db_connector(db_type=new_type) + # 간단한 SELECT 1 테스트 (DB마다 상이할 수 있음) + test_sql = ( + "SELECT 1" + if new_type not in {"oracle", "snowflake", "trino"} + else { + "oracle": "SELECT 1 FROM dual", + "snowflake": "SELECT 1", + "trino": "SELECT 1", + }[new_type] + ) + df = connector.run_sql(test_sql) + st.success(f"연결 성공. 결과 행 수: {len(df)}") + connector.close() + except Exception as e: + st.error(f"연결 테스트 실패: {e}") + + st.divider() + # 추가 폼 + st.write("DB 프로파일 추가") + name = st.text_input("이름", key="db_new_name") + db_type = st.selectbox("타입", options=DB_TYPES, key="db_new_type") + + values_new: dict[str, object] = {} + for label, k in _non_secret_fields(db_type): + v = st.text_input(label, key=f"db_new_val_{k}") + if v != "": + values_new[k] = v + + extra_new: dict[str, str] = {} + for label, k in _extra_non_secret_fields(db_type): + v = st.text_input(label, key=f"db_new_extra_{k}") + if v != "": + extra_new[k] = v + if extra_new: + values_new["extra"] = extra_new + + secrets_new: dict[str, str] = {} + for label, k in _secret_fields(db_type): + sv = st.text_input(label, key=f"db_new_secret_{k}") + if sv != "": + secrets_new[k] = sv + + cols2 = st.columns([1, 1, 2]) + with cols2[0]: + if st.button("검증", key="db_new_validate"): + try: + update_db_settings( + db_type=db_type, values=values_new, secrets=secrets_new + ) + # load back config for the type to ensure required fields presence + _ = load_config_from_env(db_type.upper()) + st.success("형식 검증 완료.") + except Exception as e: + st.error(f"검증 실패: {e}") + with cols2[1]: + if st.button("추가", key="db_new_add"): + try: + if not name: + st.warning("이름을 입력하세요.") + else: + add_db_connection( + name=name, + db_type=db_type, + host=str(values_new.get("host") or "") or None, + port=( + int(values_new.get("port")) + if str(values_new.get("port") or "").isdigit() + else None + ), + user=str(values_new.get("user") or "") or None, + password=(secrets_new.get("password") or None), + database=str(values_new.get("database") or "") or None, + extra=values_new.get("extra"), + note=None, + ) + st.success("추가되었습니다.") + st.rerun() + except Exception as e: + st.error(f"추가 실패: {e}") diff --git a/interface/app_pages/settings_sections/llm_section.py b/interface/app_pages/settings_sections/llm_section.py index cbf116c..f5fe59f 100644 --- a/interface/app_pages/settings_sections/llm_section.py +++ b/interface/app_pages/settings_sections/llm_section.py @@ -6,6 +6,10 @@ update_embedding_settings, Config, load_config, + save_llm_profile, + get_llm_registry, + save_embedding_profile, + get_embedding_registry, ) @@ -133,6 +137,7 @@ def render_llm_section(config: Config | None = None) -> None: ) fields = _llm_fields(provider) values: dict[str, str | None] = {} + non_secret_values: dict[str, str | None] = {} for label, env_key, is_secret in fields: prefill = st.session_state.get(env_key) or os.getenv(env_key) or "" if is_secret: @@ -140,31 +145,55 @@ def render_llm_section(config: Config | None = None) -> None: label, value=prefill, type="password", key=f"llm_{env_key}" ) else: - values[env_key] = st.text_input( - label, value=prefill, key=f"llm_{env_key}" - ) + v = st.text_input(label, value=prefill, key=f"llm_{env_key}") + values[env_key] = v + non_secret_values[env_key] = v - # 메시지 영역: 버튼 컬럼 밖(섹션 폭)으로 배치하여 좁은 폭에 눌려 깨지는 문제 방지 + # 메시지 영역 llm_msg = st.empty() - save_cols = st.columns([1, 1, 2]) - with save_cols[0]: - if st.button("저장", key="llm_save"): - try: - update_llm_settings(provider=provider, values=values) - llm_msg.success("LLM 설정이 저장되었습니다.") - except Exception as e: - llm_msg.error(f"저장 실패: {e}") - with save_cols[1]: - if st.button("검증", key="llm_validate"): - # 가벼운 검증: 필수 키 존재 여부만 확인 + st.markdown("**프로파일 저장 (비밀키 제외)**") + with st.form("llm_profile_save_form"): + prof_cols = st.columns([2, 2]) + with prof_cols[0]: + profile_name = st.text_input("프로파일 이름", key="llm_profile_name") + with prof_cols[1]: + profile_note = st.text_input("메모(선택)", key="llm_profile_note") + + submitted = st.form_submit_button("프로파일 저장") + if submitted: try: - update_llm_settings(provider=provider, values=values) - llm_msg.success( - "형식 검증 완료. 실제 호출은 실행 경로에서 재검증됩니다." - ) + if not profile_name: + llm_msg.warning("프로파일 이름을 입력하세요.") + else: + # 1) 환경/세션에 즉시 적용 (저장된 모든 값 사용: 사용자 요청) + update_llm_settings(provider=provider, values=values) + # 2) 디스크에 프로파일 저장 (비밀키 포함) + save_llm_profile( + name=profile_name, + provider=provider, + values=values, + note=(profile_note or None), + ) + llm_msg.success("프로파일이 저장 및 적용되었습니다.") except Exception as e: - llm_msg.error(f"검증 실패: {e}") + llm_msg.error(f"프로파일 저장 실패: {e}") + + # 저장된 프로파일 미리보기 + reg = get_llm_registry() + if reg.profiles: + with st.expander("저장된 LLM 프로파일", expanded=False): + for p in reg.profiles: + if p.fields: + pairs = [ + f"{k}={p.fields.get(k, '')}" + for k in sorted(p.fields.keys()) + ] + fields_text = ", ".join(pairs) + else: + fields_text = "-" + note_text = f" | note: {p.note}" if getattr(p, "note", None) else "" + st.caption(f"- {p.name} ({p.provider}) | {fields_text}{note_text}") with emb_col: st.markdown("**Embeddings**") @@ -201,18 +230,45 @@ def render_llm_section(config: Config | None = None) -> None: # 메시지 영역: 버튼 컬럼 밖(섹션 폭) emb_msg = st.empty() - e_cols = st.columns([1, 1, 2]) - with e_cols[0]: - if st.button("저장", key="emb_save"): - try: - update_embedding_settings(provider=e_provider, values=e_values) - emb_msg.success("Embeddings 설정이 저장되었습니다.") - except Exception as e: - emb_msg.error(f"저장 실패: {e}") - with e_cols[1]: - if st.button("검증", key="emb_validate"): + st.markdown("**Embeddings 프로파일 저장 (시크릿 포함)**") + with st.form("embedding_profile_save_form"): + e_prof_cols = st.columns([2, 2]) + with e_prof_cols[0]: + e_profile_name = st.text_input( + "프로파일 이름", key="embedding_profile_name" + ) + with e_prof_cols[1]: + e_profile_note = st.text_input( + "메모(선택)", key="embedding_profile_note" + ) + + e_submitted = st.form_submit_button("프로파일 저장") + if e_submitted: try: - update_embedding_settings(provider=e_provider, values=e_values) - emb_msg.success("형식 검증 완료.") + if not e_profile_name: + emb_msg.warning("프로파일 이름을 입력하세요.") + else: + update_embedding_settings(provider=e_provider, values=e_values) + save_embedding_profile( + name=e_profile_name, + provider=e_provider, + values=e_values, + note=(e_profile_note or None), + ) + emb_msg.success("Embeddings 프로파일이 저장 및 적용되었습니다.") except Exception as e: - emb_msg.error(f"검증 실패: {e}") + emb_msg.error(f"프로파일 저장 실패: {e}") + e_reg = get_embedding_registry() + if e_reg.profiles: + with st.expander("저장된 Embeddings 프로파일", expanded=False): + for p in e_reg.profiles: + if p.fields: + pairs = [ + f"{k}={p.fields.get(k, '')}" + for k in sorted(p.fields.keys()) + ] + fields_text = ", ".join(pairs) + else: + fields_text = "-" + note_text = f" | note: {p.note}" if getattr(p, "note", None) else "" + st.caption(f"- {p.name} ({p.provider}) | {fields_text}{note_text}") diff --git a/interface/core/config.py b/interface/core/config.py index df6d616..01ffd8b 100644 --- a/interface/core/config.py +++ b/interface/core/config.py @@ -48,6 +48,63 @@ class DataSourcesRegistry: vectordb: List[VectorDBSource] = field(default_factory=list) +# ---- DB Connections registry (non-sensitive persistence) ---- + + +@dataclass +class DBConnectionProfile: + name: str + type: str # 'postgresql' | 'mysql' | 'mariadb' | 'oracle' | 'clickhouse' | 'duckdb' | 'sqlite' | 'databricks' | 'snowflake' | 'trino' + host: Optional[str] = None + port: Optional[int] = None + user: Optional[str] = None + password: Optional[str] = None + database: Optional[str] = None + extra: Optional[Dict[str, Any]] = None # non-secret + note: Optional[str] = None + + +@dataclass +class DBConnectionsRegistry: + connections: List[DBConnectionProfile] = field(default_factory=list) + + +# ---- LLM profiles registry (non-sensitive persistence) ---- + + +@dataclass +class LLMProfile: + name: str + provider: ( + str # 'openai' | 'azure' | 'bedrock' | 'gemini' | 'ollama' | 'huggingface' + ) + fields: Dict[str, str] = field(default_factory=dict) # includes secrets + note: Optional[str] = None + + +@dataclass +class LLMRegistry: + profiles: List[LLMProfile] = field(default_factory=list) + + +# ---- Embedding profiles registry (includes secrets) ---- + + +@dataclass +class EmbeddingProfile: + name: str + provider: ( + str # 'openai' | 'azure' | 'bedrock' | 'gemini' | 'ollama' | 'huggingface' + ) + fields: Dict[str, str] = field(default_factory=dict) + note: Optional[str] = None + + +@dataclass +class EmbeddingRegistry: + profiles: List[EmbeddingProfile] = field(default_factory=list) + + def _get_session_value(key: str) -> str | None: if st is None: return None @@ -119,6 +176,19 @@ def get_data_sources_registry() -> DataSourcesRegistry: return registry +def get_db_connections_registry() -> DBConnectionsRegistry: + if st is not None and "db_connections_registry" in st.session_state: + reg = st.session_state["db_connections_registry"] + return reg # stored as DBConnectionsRegistry + try: + registry = load_db_registry_from_disk() + except Exception: + registry = DBConnectionsRegistry() + if st is not None: + st.session_state["db_connections_registry"] = registry + return registry + + def _save_registry(registry: DataSourcesRegistry) -> None: if st is not None: st.session_state["data_sources_registry"] = registry @@ -129,6 +199,58 @@ def _save_registry(registry: DataSourcesRegistry) -> None: pass +def _save_db_registry(registry: DBConnectionsRegistry) -> None: + if st is not None: + st.session_state["db_connections_registry"] = registry + try: + save_db_registry_to_disk(registry) + except Exception: + # fail-soft; UI will still have session copy + pass + + +def get_llm_registry() -> LLMRegistry: + if st is not None and "llm_registry" in st.session_state: + return st.session_state["llm_registry"] + try: + registry = load_llm_registry_from_disk() + except Exception: + registry = LLMRegistry() + if st is not None: + st.session_state["llm_registry"] = registry + return registry + + +def _save_llm_registry(registry: LLMRegistry) -> None: + if st is not None: + st.session_state["llm_registry"] = registry + try: + save_llm_registry_to_disk(registry) + except Exception: + pass + + +def get_embedding_registry() -> EmbeddingRegistry: + if st is not None and "embedding_registry" in st.session_state: + return st.session_state["embedding_registry"] + try: + registry = load_embedding_registry_from_disk() + except Exception: + registry = EmbeddingRegistry() + if st is not None: + st.session_state["embedding_registry"] = registry + return registry + + +def _save_embedding_registry(registry: EmbeddingRegistry) -> None: + if st is not None: + st.session_state["embedding_registry"] = registry + try: + save_embedding_registry_to_disk(registry) + except Exception: + pass + + # ---- Disk persistence for registry ---- @@ -140,6 +262,28 @@ def _get_registry_file_path() -> Path: return Path(os.getcwd()) / "config" / "data_sources.json" +def _get_db_registry_file_path() -> Path: + # Allow override via env var, else default to ./config/db_connections.json + override = os.getenv("LANG2SQL_DB_REGISTRY_PATH") + if override: + return Path(override).expanduser().resolve() + return Path(os.getcwd()) / "config" / "db_connections.json" + + +def _get_llm_registry_file_path() -> Path: + override = os.getenv("LANG2SQL_LLM_REGISTRY_PATH") + if override: + return Path(override).expanduser().resolve() + return Path(os.getcwd()) / "config" / "llm_profiles.json" + + +def _get_embedding_registry_file_path() -> Path: + override = os.getenv("LANG2SQL_EMBEDDING_REGISTRY_PATH") + if override: + return Path(override).expanduser().resolve() + return Path(os.getcwd()) / "config" / "embedding_profiles.json" + + def _ensure_parent_dir(path: Path) -> None: path.parent.mkdir(parents=True, exist_ok=True) @@ -152,6 +296,30 @@ def save_registry_to_disk(registry: DataSourcesRegistry) -> None: json.dump(payload, f, ensure_ascii=False, indent=2) +def save_db_registry_to_disk(registry: DBConnectionsRegistry) -> None: + path = _get_db_registry_file_path() + _ensure_parent_dir(path) + payload = asdict(registry) + with path.open("w", encoding="utf-8") as f: + json.dump(payload, f, ensure_ascii=False, indent=2) + + +def save_llm_registry_to_disk(registry: LLMRegistry) -> None: + path = _get_llm_registry_file_path() + _ensure_parent_dir(path) + payload = asdict(registry) + with path.open("w", encoding="utf-8") as f: + json.dump(payload, f, ensure_ascii=False, indent=2) + + +def save_embedding_registry_to_disk(registry: EmbeddingRegistry) -> None: + path = _get_embedding_registry_file_path() + _ensure_parent_dir(path) + payload = asdict(registry) + with path.open("w", encoding="utf-8") as f: + json.dump(payload, f, ensure_ascii=False, indent=2) + + def _parse_datahub_list(items: List[Dict[str, Any]]) -> List[DataHubSource]: parsed: List[DataHubSource] = [] for item in items or []: @@ -201,6 +369,105 @@ def load_registry_from_disk() -> DataSourcesRegistry: ) +def _parse_db_conn_list(items: List[Dict[str, Any]]) -> List[DBConnectionProfile]: + parsed: List[DBConnectionProfile] = [] + for item in items or []: + name = str(item.get("name", "")).strip() + db_type = str(item.get("type", "")).strip().lower() + if not name or not db_type: + continue + host = item.get("host") + port = item.get("port") + try: + port = int(port) if port is not None else None + except Exception: + port = None + user = item.get("user") + password = item.get("password") + database = item.get("database") + extra = item.get("extra") or None + note = item.get("note") or None + parsed.append( + DBConnectionProfile( + name=name, + type=db_type, + host=host, + port=port, + user=user, + password=password, + database=database, + extra=extra, + note=note, + ) + ) + return parsed + + +def load_db_registry_from_disk() -> DBConnectionsRegistry: + path = _get_db_registry_file_path() + if not path.exists(): + return DBConnectionsRegistry() + with path.open("r", encoding="utf-8") as f: + data: Dict[str, Any] = json.load(f) + return DBConnectionsRegistry( + connections=_parse_db_conn_list(data.get("connections", [])) + ) + + +def _parse_llm_profiles(items: List[Dict[str, Any]]) -> List[LLMProfile]: + parsed: List[LLMProfile] = [] + for item in items or []: + name = str(item.get("name", "")).strip() + provider = str(item.get("provider", "")).strip().lower() + if not name or not provider: + continue + fields = item.get("fields") or {} + note = item.get("note") or None + if not isinstance(fields, dict): + fields = {} + parsed.append( + LLMProfile(name=name, provider=provider, fields=fields, note=note) + ) + return parsed + + +def load_llm_registry_from_disk() -> LLMRegistry: + path = _get_llm_registry_file_path() + if not path.exists(): + return LLMRegistry() + with path.open("r", encoding="utf-8") as f: + data: Dict[str, Any] = json.load(f) + return LLMRegistry(profiles=_parse_llm_profiles(data.get("profiles", []))) + + +def _parse_embedding_profiles(items: List[Dict[str, Any]]) -> List[EmbeddingProfile]: + parsed: List[EmbeddingProfile] = [] + for item in items or []: + name = str(item.get("name", "")).strip() + provider = str(item.get("provider", "")).strip().lower() + if not name or not provider: + continue + fields = item.get("fields") or {} + note = item.get("note") or None + if not isinstance(fields, dict): + fields = {} + parsed.append( + EmbeddingProfile(name=name, provider=provider, fields=fields, note=note) + ) + return parsed + + +def load_embedding_registry_from_disk() -> EmbeddingRegistry: + path = _get_embedding_registry_file_path() + if not path.exists(): + return EmbeddingRegistry() + with path.open("r", encoding="utf-8") as f: + data: Dict[str, Any] = json.load(f) + return EmbeddingRegistry( + profiles=_parse_embedding_profiles(data.get("profiles", [])) + ) + + def add_datahub_source( *, name: str, url: str, faiss_path: Optional[str] = None, note: Optional[str] = None ) -> None: @@ -301,6 +568,130 @@ def update_data_source_mode(config: Config, mode: str | None) -> None: pass +# ---- DB Connections registry ops ---- + + +def add_db_connection( + *, + name: str, + db_type: str, + host: Optional[str] = None, + port: Optional[int] = None, + user: Optional[str] = None, + password: Optional[str] = None, + database: Optional[str] = None, + extra: Optional[Dict[str, Any]] = None, + note: Optional[str] = None, +) -> None: + db_type_norm = (db_type or "").lower() + registry = get_db_connections_registry() + if any(c.name == name for c in registry.connections): + raise ValueError(f"이미 존재하는 DB 프로파일 이름입니다: {name}") + registry.connections.append( + DBConnectionProfile( + name=name, + type=db_type_norm, + host=host, + port=port, + user=user, + password=password, + database=database, + extra=extra or None, + note=note or None, + ) + ) + _save_db_registry(registry) + + +def update_db_connection( + *, + name: str, + db_type: str, + host: Optional[str], + port: Optional[int], + user: Optional[str], + password: Optional[str], + database: Optional[str], + extra: Optional[Dict[str, Any]], + note: Optional[str], +) -> None: + db_type_norm = (db_type or "").lower() + registry = get_db_connections_registry() + for idx, c in enumerate(registry.connections): + if c.name == name: + registry.connections[idx] = DBConnectionProfile( + name=name, + type=db_type_norm, + host=host, + port=port, + user=user, + password=password, + database=database, + extra=extra or None, + note=note or None, + ) + _save_db_registry(registry) + return + raise ValueError(f"존재하지 않는 DB 프로파일 이름입니다: {name}") + + +def delete_db_connection(*, name: str) -> None: + registry = get_db_connections_registry() + registry.connections = [c for c in registry.connections if c.name != name] + _save_db_registry(registry) + + +# ---- DB env/session update (secrets-only in-memory) ---- + + +def update_db_settings( + *, + db_type: str, + values: Dict[str, Any] | None, + secrets: Dict[str, Optional[str]] | None = None, +) -> None: + """Update DB settings into process env and session. + + Only non-sensitive values should be passed in values and may come from registry. + Secrets (e.g., PASSWORD, ACCESS_TOKEN) are applied to env/session but never persisted to disk. + """ + db_type_norm = (db_type or "").lower() + if not db_type_norm: + raise ValueError("DB 타입이 비어 있습니다.") + + # Core selector + _put_env("DB_TYPE", db_type_norm) + _put_session("DB_TYPE", db_type_norm) + + prefix = db_type_norm.upper() + + base_keys = ["HOST", "PORT", "USER", "DATABASE"] + for base_key in base_keys: + vk = base_key.lower() + v = (values or {}).get(vk) + if v is None: + continue + _put_env(f"{prefix}_{base_key}", str(v)) + _put_session(f"{prefix}_{base_key}", str(v)) + + # Extras (non-secret) + extra = (values or {}).get("extra") or {} + if isinstance(extra, dict): + for k, v in extra.items(): + if v is None: + continue + _put_env(f"{prefix}_{str(k).upper()}", str(v)) + _put_session(f"{prefix}_{str(k).upper()}", str(v)) + + # Secrets (applied to env+session, never persisted) + for sk, sv in (secrets or {}).items(): + if sv is None: + continue + key_up = str(sk).upper() + _put_env(f"{prefix}_{key_up}", str(sv)) + _put_session(f"{prefix}_{key_up}", str(sv)) + + def update_vectordb_settings( config: Config, *, vectordb_type: str, vectordb_location: str | None ) -> None: @@ -397,6 +788,36 @@ def update_llm_settings(*, provider: str, values: dict[str, str | None]) -> None _put_session(k, v) +def save_llm_profile( + *, + name: str, + provider: str, + values: dict[str, str | None], + note: Optional[str] = None, +) -> None: + """Persist an LLM profile including secrets (explicit per user's request).""" + provider_norm = (provider or "").lower() + stored_fields: Dict[str, str] = {} + for k, v in (values or {}).items(): + if v is None: + continue + stored_fields[k] = str(v) + + reg = get_llm_registry() + # upsert by name + for idx, p in enumerate(reg.profiles): + if p.name == name: + reg.profiles[idx] = LLMProfile( + name=name, provider=provider_norm, fields=stored_fields, note=note + ) + _save_llm_registry(reg) + return + reg.profiles.append( + LLMProfile(name=name, provider=provider_norm, fields=stored_fields, note=note) + ) + _save_llm_registry(reg) + + def update_embedding_settings(*, provider: str, values: dict[str, str | None]) -> None: """Update Embeddings settings from UI into process env and session. @@ -420,3 +841,33 @@ def update_embedding_settings(*, provider: str, values: dict[str, str | None]) - if v is not None: _put_env(k, v) _put_session(k, v) + + +def save_embedding_profile( + *, + name: str, + provider: str, + values: dict[str, str | None], + note: Optional[str] = None, +) -> None: + provider_norm = (provider or "").lower() + stored_fields: Dict[str, str] = {} + for k, v in (values or {}).items(): + if v is None: + continue + stored_fields[k] = str(v) + + reg = get_embedding_registry() + for idx, p in enumerate(reg.profiles): + if p.name == name: + reg.profiles[idx] = EmbeddingProfile( + name=name, provider=provider_norm, fields=stored_fields, note=note + ) + _save_embedding_registry(reg) + return + reg.profiles.append( + EmbeddingProfile( + name=name, provider=provider_norm, fields=stored_fields, note=note + ) + ) + _save_embedding_registry(reg) From 12952960452d97ed068d49d11571e4d8df05106a Mon Sep 17 00:00:00 2001 From: ehddnr301 Date: Sat, 4 Oct 2025 22:21:59 +0900 Subject: [PATCH 06/11] =?UTF-8?q?=EC=84=A4=EC=A0=95=20=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=80=EC=97=90=EC=84=9C=20=EB=94=94=EB=B0=94=EC=9D=B4?= =?UTF-8?q?=EC=8A=A4=20=ED=83=AD=20=EC=A0=9C=EA=B1=B0=20=EB=B0=8F=20?= =?UTF-8?q?=EA=B4=80=EB=A0=A8=20=EC=A0=95=EB=B3=B4=20=EB=A9=94=EC=8B=9C?= =?UTF-8?q?=EC=A7=80=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- interface/app_pages/settings.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/interface/app_pages/settings.py b/interface/app_pages/settings.py index b43b66a..21e693e 100644 --- a/interface/app_pages/settings.py +++ b/interface/app_pages/settings.py @@ -16,7 +16,7 @@ config = load_config() -tabs = st.tabs(["데이터 소스", "LLM", "DB", "Device"]) +tabs = st.tabs(["데이터 소스", "LLM", "DB"]) with tabs[0]: render_data_source_section(config) @@ -27,8 +27,5 @@ with tabs[2]: render_db_section() -with tabs[3]: - st.info("디바이스 설정은 곧 제공됩니다.") - st.divider() st.caption("민감 정보는 로그에 기록되지 않으며, 이 설정은 현재 세션에 우선 반영됩니다.") From 656cbdd77549b1258727062111031c869272f311 Mon Sep 17 00:00:00 2001 From: ehddnr301 Date: Sat, 4 Oct 2025 22:36:51 +0900 Subject: [PATCH 07/11] =?UTF-8?q?=EC=84=A4=EC=A0=95=20=EA=B4=80=EB=A0=A8?= =?UTF-8?q?=20=EB=AA=A8=EB=93=88=EC=9D=84=20=EB=B6=84=EB=A6=AC=ED=95=98?= =?UTF-8?q?=EC=97=AC=20=EC=9E=AC=EA=B5=AC=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- interface/core/config.py | 873 ------------------ interface/core/config/__init__.py | 144 +++ interface/core/config/models.py | 83 ++ interface/core/config/paths.py | 38 + interface/core/config/persist.py | 205 ++++ .../core/config/registry_data_sources.py | 130 +++ interface/core/config/registry_db.py | 106 +++ interface/core/config/registry_llm.py | 114 +++ interface/core/config/settings.py | 251 +++++ 9 files changed, 1071 insertions(+), 873 deletions(-) delete mode 100644 interface/core/config.py create mode 100644 interface/core/config/__init__.py create mode 100644 interface/core/config/models.py create mode 100644 interface/core/config/paths.py create mode 100644 interface/core/config/persist.py create mode 100644 interface/core/config/registry_data_sources.py create mode 100644 interface/core/config/registry_db.py create mode 100644 interface/core/config/registry_llm.py create mode 100644 interface/core/config/settings.py diff --git a/interface/core/config.py b/interface/core/config.py deleted file mode 100644 index 01ffd8b..0000000 --- a/interface/core/config.py +++ /dev/null @@ -1,873 +0,0 @@ -from dataclasses import dataclass, field, asdict -from typing import List, Optional, Any, Dict -import os -from pathlib import Path -import json - -try: - import streamlit as st # type: ignore -except Exception: # pragma: no cover - streamlit may not be present in non-UI contexts - st = None # type: ignore - -from llm_utils.tools import set_gms_server - - -DEFAULT_DATAHUB_SERVER = "http://localhost:8080" -DEFAULT_VECTORDB_TYPE = os.getenv("VECTORDB_TYPE", "faiss").lower() -DEFAULT_VECTORDB_LOCATION = os.getenv("VECTORDB_LOCATION", "") - - -@dataclass -class Config: - datahub_server: str = DEFAULT_DATAHUB_SERVER - vectordb_type: str = DEFAULT_VECTORDB_TYPE - vectordb_location: str = DEFAULT_VECTORDB_LOCATION - data_source_mode: str | None = None # "datahub" | "vectordb" | None - - -@dataclass -class DataHubSource: - name: str - url: str - faiss_path: Optional[str] = None - note: Optional[str] = None - - -@dataclass -class VectorDBSource: - name: str - type: str # 'faiss' | 'pgvector' - location: str - collection_prefix: Optional[str] = None - note: Optional[str] = None - - -@dataclass -class DataSourcesRegistry: - datahub: List[DataHubSource] = field(default_factory=list) - vectordb: List[VectorDBSource] = field(default_factory=list) - - -# ---- DB Connections registry (non-sensitive persistence) ---- - - -@dataclass -class DBConnectionProfile: - name: str - type: str # 'postgresql' | 'mysql' | 'mariadb' | 'oracle' | 'clickhouse' | 'duckdb' | 'sqlite' | 'databricks' | 'snowflake' | 'trino' - host: Optional[str] = None - port: Optional[int] = None - user: Optional[str] = None - password: Optional[str] = None - database: Optional[str] = None - extra: Optional[Dict[str, Any]] = None # non-secret - note: Optional[str] = None - - -@dataclass -class DBConnectionsRegistry: - connections: List[DBConnectionProfile] = field(default_factory=list) - - -# ---- LLM profiles registry (non-sensitive persistence) ---- - - -@dataclass -class LLMProfile: - name: str - provider: ( - str # 'openai' | 'azure' | 'bedrock' | 'gemini' | 'ollama' | 'huggingface' - ) - fields: Dict[str, str] = field(default_factory=dict) # includes secrets - note: Optional[str] = None - - -@dataclass -class LLMRegistry: - profiles: List[LLMProfile] = field(default_factory=list) - - -# ---- Embedding profiles registry (includes secrets) ---- - - -@dataclass -class EmbeddingProfile: - name: str - provider: ( - str # 'openai' | 'azure' | 'bedrock' | 'gemini' | 'ollama' | 'huggingface' - ) - fields: Dict[str, str] = field(default_factory=dict) - note: Optional[str] = None - - -@dataclass -class EmbeddingRegistry: - profiles: List[EmbeddingProfile] = field(default_factory=list) - - -def _get_session_value(key: str) -> str | None: - if st is None: - return None - try: - if key in st.session_state and st.session_state[key]: - return str(st.session_state[key]) - except Exception: - return None - return None - - -def load_config() -> Config: - """Load configuration with priority: session_state > environment > defaults.""" - datahub = _get_session_value("datahub_server") or os.getenv( - "DATAHUB_SERVER", DEFAULT_DATAHUB_SERVER - ) - mode = _get_session_value("data_source_mode") - - vectordb_type = _get_session_value("vectordb_type") or os.getenv( - "VECTORDB_TYPE", DEFAULT_VECTORDB_TYPE - ) - vectordb_location = _get_session_value("vectordb_location") or os.getenv( - "VECTORDB_LOCATION", DEFAULT_VECTORDB_LOCATION - ) - - return Config( - datahub_server=datahub, - vectordb_type=vectordb_type.lower() if vectordb_type else DEFAULT_VECTORDB_TYPE, - vectordb_location=vectordb_location, - data_source_mode=mode, - ) - - -def update_datahub_server(config: Config, new_url: str) -> None: - """Update DataHub server URL across runtime config, env-aware clients, and session.""" - if not new_url: - return - config.datahub_server = new_url - - # Propagate to underlying tooling/clients - try: - set_gms_server(new_url) - except Exception: - # Fail-soft: UI should surface errors from callers if needed - pass - - # Reflect into session state for immediate UI reuse - if st is not None: - try: - st.session_state["datahub_server"] = new_url - except Exception: - pass - - -# ---- Registry helpers ---- - - -def get_data_sources_registry() -> DataSourcesRegistry: - if st is not None and "data_sources_registry" in st.session_state: - reg = st.session_state["data_sources_registry"] - return reg # stored as DataSourcesRegistry - # Try load from disk - try: - registry = load_registry_from_disk() - except Exception: - registry = DataSourcesRegistry() - if st is not None: - st.session_state["data_sources_registry"] = registry - return registry - - -def get_db_connections_registry() -> DBConnectionsRegistry: - if st is not None and "db_connections_registry" in st.session_state: - reg = st.session_state["db_connections_registry"] - return reg # stored as DBConnectionsRegistry - try: - registry = load_db_registry_from_disk() - except Exception: - registry = DBConnectionsRegistry() - if st is not None: - st.session_state["db_connections_registry"] = registry - return registry - - -def _save_registry(registry: DataSourcesRegistry) -> None: - if st is not None: - st.session_state["data_sources_registry"] = registry - try: - save_registry_to_disk(registry) - except Exception: - # fail-soft; UI will still have session copy - pass - - -def _save_db_registry(registry: DBConnectionsRegistry) -> None: - if st is not None: - st.session_state["db_connections_registry"] = registry - try: - save_db_registry_to_disk(registry) - except Exception: - # fail-soft; UI will still have session copy - pass - - -def get_llm_registry() -> LLMRegistry: - if st is not None and "llm_registry" in st.session_state: - return st.session_state["llm_registry"] - try: - registry = load_llm_registry_from_disk() - except Exception: - registry = LLMRegistry() - if st is not None: - st.session_state["llm_registry"] = registry - return registry - - -def _save_llm_registry(registry: LLMRegistry) -> None: - if st is not None: - st.session_state["llm_registry"] = registry - try: - save_llm_registry_to_disk(registry) - except Exception: - pass - - -def get_embedding_registry() -> EmbeddingRegistry: - if st is not None and "embedding_registry" in st.session_state: - return st.session_state["embedding_registry"] - try: - registry = load_embedding_registry_from_disk() - except Exception: - registry = EmbeddingRegistry() - if st is not None: - st.session_state["embedding_registry"] = registry - return registry - - -def _save_embedding_registry(registry: EmbeddingRegistry) -> None: - if st is not None: - st.session_state["embedding_registry"] = registry - try: - save_embedding_registry_to_disk(registry) - except Exception: - pass - - -# ---- Disk persistence for registry ---- - - -def _get_registry_file_path() -> Path: - # Allow override via env var, else default to ./config/data_sources.json - override = os.getenv("LANG2SQL_REGISTRY_PATH") - if override: - return Path(override).expanduser().resolve() - return Path(os.getcwd()) / "config" / "data_sources.json" - - -def _get_db_registry_file_path() -> Path: - # Allow override via env var, else default to ./config/db_connections.json - override = os.getenv("LANG2SQL_DB_REGISTRY_PATH") - if override: - return Path(override).expanduser().resolve() - return Path(os.getcwd()) / "config" / "db_connections.json" - - -def _get_llm_registry_file_path() -> Path: - override = os.getenv("LANG2SQL_LLM_REGISTRY_PATH") - if override: - return Path(override).expanduser().resolve() - return Path(os.getcwd()) / "config" / "llm_profiles.json" - - -def _get_embedding_registry_file_path() -> Path: - override = os.getenv("LANG2SQL_EMBEDDING_REGISTRY_PATH") - if override: - return Path(override).expanduser().resolve() - return Path(os.getcwd()) / "config" / "embedding_profiles.json" - - -def _ensure_parent_dir(path: Path) -> None: - path.parent.mkdir(parents=True, exist_ok=True) - - -def save_registry_to_disk(registry: DataSourcesRegistry) -> None: - path = _get_registry_file_path() - _ensure_parent_dir(path) - payload = asdict(registry) - with path.open("w", encoding="utf-8") as f: - json.dump(payload, f, ensure_ascii=False, indent=2) - - -def save_db_registry_to_disk(registry: DBConnectionsRegistry) -> None: - path = _get_db_registry_file_path() - _ensure_parent_dir(path) - payload = asdict(registry) - with path.open("w", encoding="utf-8") as f: - json.dump(payload, f, ensure_ascii=False, indent=2) - - -def save_llm_registry_to_disk(registry: LLMRegistry) -> None: - path = _get_llm_registry_file_path() - _ensure_parent_dir(path) - payload = asdict(registry) - with path.open("w", encoding="utf-8") as f: - json.dump(payload, f, ensure_ascii=False, indent=2) - - -def save_embedding_registry_to_disk(registry: EmbeddingRegistry) -> None: - path = _get_embedding_registry_file_path() - _ensure_parent_dir(path) - payload = asdict(registry) - with path.open("w", encoding="utf-8") as f: - json.dump(payload, f, ensure_ascii=False, indent=2) - - -def _parse_datahub_list(items: List[Dict[str, Any]]) -> List[DataHubSource]: - parsed: List[DataHubSource] = [] - for item in items or []: - name = str(item.get("name", "")).strip() - url = str(item.get("url", "")).strip() - faiss_path = item.get("faiss_path") - note = item.get("note") - if not name or not url: - continue - parsed.append( - DataHubSource(name=name, url=url, faiss_path=faiss_path, note=note) - ) - return parsed - - -def _parse_vectordb_list(items: List[Dict[str, Any]]) -> List[VectorDBSource]: - parsed: List[VectorDBSource] = [] - for item in items or []: - name = str(item.get("name", "")).strip() - vtype = str(item.get("type", "")).strip().lower() - location = str(item.get("location", "")).strip() - if not name or not vtype or not location: - continue - collection_prefix = item.get("collection_prefix") - note = item.get("note") - parsed.append( - VectorDBSource( - name=name, - type=vtype, - location=location, - collection_prefix=collection_prefix, - note=note, - ) - ) - return parsed - - -def load_registry_from_disk() -> DataSourcesRegistry: - path = _get_registry_file_path() - if not path.exists(): - return DataSourcesRegistry() - with path.open("r", encoding="utf-8") as f: - data: Dict[str, Any] = json.load(f) - return DataSourcesRegistry( - datahub=_parse_datahub_list(data.get("datahub", [])), - vectordb=_parse_vectordb_list(data.get("vectordb", [])), - ) - - -def _parse_db_conn_list(items: List[Dict[str, Any]]) -> List[DBConnectionProfile]: - parsed: List[DBConnectionProfile] = [] - for item in items or []: - name = str(item.get("name", "")).strip() - db_type = str(item.get("type", "")).strip().lower() - if not name or not db_type: - continue - host = item.get("host") - port = item.get("port") - try: - port = int(port) if port is not None else None - except Exception: - port = None - user = item.get("user") - password = item.get("password") - database = item.get("database") - extra = item.get("extra") or None - note = item.get("note") or None - parsed.append( - DBConnectionProfile( - name=name, - type=db_type, - host=host, - port=port, - user=user, - password=password, - database=database, - extra=extra, - note=note, - ) - ) - return parsed - - -def load_db_registry_from_disk() -> DBConnectionsRegistry: - path = _get_db_registry_file_path() - if not path.exists(): - return DBConnectionsRegistry() - with path.open("r", encoding="utf-8") as f: - data: Dict[str, Any] = json.load(f) - return DBConnectionsRegistry( - connections=_parse_db_conn_list(data.get("connections", [])) - ) - - -def _parse_llm_profiles(items: List[Dict[str, Any]]) -> List[LLMProfile]: - parsed: List[LLMProfile] = [] - for item in items or []: - name = str(item.get("name", "")).strip() - provider = str(item.get("provider", "")).strip().lower() - if not name or not provider: - continue - fields = item.get("fields") or {} - note = item.get("note") or None - if not isinstance(fields, dict): - fields = {} - parsed.append( - LLMProfile(name=name, provider=provider, fields=fields, note=note) - ) - return parsed - - -def load_llm_registry_from_disk() -> LLMRegistry: - path = _get_llm_registry_file_path() - if not path.exists(): - return LLMRegistry() - with path.open("r", encoding="utf-8") as f: - data: Dict[str, Any] = json.load(f) - return LLMRegistry(profiles=_parse_llm_profiles(data.get("profiles", []))) - - -def _parse_embedding_profiles(items: List[Dict[str, Any]]) -> List[EmbeddingProfile]: - parsed: List[EmbeddingProfile] = [] - for item in items or []: - name = str(item.get("name", "")).strip() - provider = str(item.get("provider", "")).strip().lower() - if not name or not provider: - continue - fields = item.get("fields") or {} - note = item.get("note") or None - if not isinstance(fields, dict): - fields = {} - parsed.append( - EmbeddingProfile(name=name, provider=provider, fields=fields, note=note) - ) - return parsed - - -def load_embedding_registry_from_disk() -> EmbeddingRegistry: - path = _get_embedding_registry_file_path() - if not path.exists(): - return EmbeddingRegistry() - with path.open("r", encoding="utf-8") as f: - data: Dict[str, Any] = json.load(f) - return EmbeddingRegistry( - profiles=_parse_embedding_profiles(data.get("profiles", [])) - ) - - -def add_datahub_source( - *, name: str, url: str, faiss_path: Optional[str] = None, note: Optional[str] = None -) -> None: - registry = get_data_sources_registry() - if any(s.name == name for s in registry.datahub): - raise ValueError(f"이미 존재하는 DataHub 이름입니다: {name}") - registry.datahub.append( - DataHubSource(name=name, url=url, faiss_path=faiss_path, note=note) - ) - _save_registry(registry) - - -def update_datahub_source( - *, name: str, url: str, faiss_path: Optional[str], note: Optional[str] -) -> None: - registry = get_data_sources_registry() - for idx, s in enumerate(registry.datahub): - if s.name == name: - registry.datahub[idx] = DataHubSource( - name=name, url=url, faiss_path=faiss_path, note=note - ) - _save_registry(registry) - return - raise ValueError(f"존재하지 않는 DataHub 이름입니다: {name}") - - -def delete_datahub_source(*, name: str) -> None: - registry = get_data_sources_registry() - registry.datahub = [s for s in registry.datahub if s.name != name] - _save_registry(registry) - - -def add_vectordb_source( - *, - name: str, - vtype: str, - location: str, - collection_prefix: Optional[str] = None, - note: Optional[str] = None, -) -> None: - vtype = (vtype or "").lower() - if vtype not in ("faiss", "pgvector"): - raise ValueError("VectorDB 타입은 'faiss' 또는 'pgvector'여야 합니다") - registry = get_data_sources_registry() - if any(s.name == name for s in registry.vectordb): - raise ValueError(f"이미 존재하는 VectorDB 이름입니다: {name}") - registry.vectordb.append( - VectorDBSource( - name=name, - type=vtype, - location=location, - collection_prefix=collection_prefix, - note=note, - ) - ) - _save_registry(registry) - - -def update_vectordb_source( - *, - name: str, - vtype: str, - location: str, - collection_prefix: Optional[str], - note: Optional[str], -) -> None: - vtype = (vtype or "").lower() - if vtype not in ("faiss", "pgvector"): - raise ValueError("VectorDB 타입은 'faiss' 또는 'pgvector'여야 합니다") - registry = get_data_sources_registry() - for idx, s in enumerate(registry.vectordb): - if s.name == name: - registry.vectordb[idx] = VectorDBSource( - name=name, - type=vtype, - location=location, - collection_prefix=collection_prefix, - note=note, - ) - _save_registry(registry) - return - raise ValueError(f"존재하지 않는 VectorDB 이름입니다: {name}") - - -def delete_vectordb_source(*, name: str) -> None: - registry = get_data_sources_registry() - registry.vectordb = [s for s in registry.vectordb if s.name != name] - _save_registry(registry) - - -def update_data_source_mode(config: Config, mode: str | None) -> None: - """Persist user's data source selection (datahub | vectordb).""" - config.data_source_mode = mode - if st is not None: - try: - st.session_state["data_source_mode"] = mode - except Exception: - pass - - -# ---- DB Connections registry ops ---- - - -def add_db_connection( - *, - name: str, - db_type: str, - host: Optional[str] = None, - port: Optional[int] = None, - user: Optional[str] = None, - password: Optional[str] = None, - database: Optional[str] = None, - extra: Optional[Dict[str, Any]] = None, - note: Optional[str] = None, -) -> None: - db_type_norm = (db_type or "").lower() - registry = get_db_connections_registry() - if any(c.name == name for c in registry.connections): - raise ValueError(f"이미 존재하는 DB 프로파일 이름입니다: {name}") - registry.connections.append( - DBConnectionProfile( - name=name, - type=db_type_norm, - host=host, - port=port, - user=user, - password=password, - database=database, - extra=extra or None, - note=note or None, - ) - ) - _save_db_registry(registry) - - -def update_db_connection( - *, - name: str, - db_type: str, - host: Optional[str], - port: Optional[int], - user: Optional[str], - password: Optional[str], - database: Optional[str], - extra: Optional[Dict[str, Any]], - note: Optional[str], -) -> None: - db_type_norm = (db_type or "").lower() - registry = get_db_connections_registry() - for idx, c in enumerate(registry.connections): - if c.name == name: - registry.connections[idx] = DBConnectionProfile( - name=name, - type=db_type_norm, - host=host, - port=port, - user=user, - password=password, - database=database, - extra=extra or None, - note=note or None, - ) - _save_db_registry(registry) - return - raise ValueError(f"존재하지 않는 DB 프로파일 이름입니다: {name}") - - -def delete_db_connection(*, name: str) -> None: - registry = get_db_connections_registry() - registry.connections = [c for c in registry.connections if c.name != name] - _save_db_registry(registry) - - -# ---- DB env/session update (secrets-only in-memory) ---- - - -def update_db_settings( - *, - db_type: str, - values: Dict[str, Any] | None, - secrets: Dict[str, Optional[str]] | None = None, -) -> None: - """Update DB settings into process env and session. - - Only non-sensitive values should be passed in values and may come from registry. - Secrets (e.g., PASSWORD, ACCESS_TOKEN) are applied to env/session but never persisted to disk. - """ - db_type_norm = (db_type or "").lower() - if not db_type_norm: - raise ValueError("DB 타입이 비어 있습니다.") - - # Core selector - _put_env("DB_TYPE", db_type_norm) - _put_session("DB_TYPE", db_type_norm) - - prefix = db_type_norm.upper() - - base_keys = ["HOST", "PORT", "USER", "DATABASE"] - for base_key in base_keys: - vk = base_key.lower() - v = (values or {}).get(vk) - if v is None: - continue - _put_env(f"{prefix}_{base_key}", str(v)) - _put_session(f"{prefix}_{base_key}", str(v)) - - # Extras (non-secret) - extra = (values or {}).get("extra") or {} - if isinstance(extra, dict): - for k, v in extra.items(): - if v is None: - continue - _put_env(f"{prefix}_{str(k).upper()}", str(v)) - _put_session(f"{prefix}_{str(k).upper()}", str(v)) - - # Secrets (applied to env+session, never persisted) - for sk, sv in (secrets or {}).items(): - if sv is None: - continue - key_up = str(sk).upper() - _put_env(f"{prefix}_{key_up}", str(sv)) - _put_session(f"{prefix}_{key_up}", str(sv)) - - -def update_vectordb_settings( - config: Config, *, vectordb_type: str, vectordb_location: str | None -) -> None: - """Validate and update VectorDB settings into env and session. - - Basic validation rules follow CLI's behavior: - - vectordb_type must be 'faiss' or 'pgvector' - - if type == 'faiss' and location provided: must be an existing directory - - if type == 'pgvector' and location provided: must start with 'postgresql://' - """ - vtype = (vectordb_type or "").lower() - if vtype not in ("faiss", "pgvector"): - raise ValueError(f"지원하지 않는 VectorDB 타입: {vectordb_type}") - - vloc = vectordb_location or "" - if vloc: - if vtype == "faiss": - path = Path(vloc) - # 신규 경로 허용: 존재하면 디렉토리인지 확인, 없으면 상위 디렉토리 생성 - if path.exists() and not path.is_dir(): - raise ValueError( - f"유효하지 않은 FAISS 디렉토리 경로(파일 경로임): {vloc}" - ) - if not path.exists(): - try: - path.mkdir(parents=True, exist_ok=True) - except Exception as e: - raise ValueError(f"FAISS 경로 생성 실패: {vloc} | {e}") - elif vtype == "pgvector": - if not vloc.startswith("postgresql://"): - raise ValueError("pgvector URL은 'postgresql://'로 시작해야 합니다") - - # Persist to runtime config - config.vectordb_type = vtype - config.vectordb_location = vloc - - # Reflect to process env for downstream modules - os.environ["VECTORDB_TYPE"] = vtype - if vloc: - os.environ["VECTORDB_LOCATION"] = vloc - - # Reflect to session state for UI - if st is not None: - try: - st.session_state["vectordb_type"] = vtype - st.session_state["vectordb_location"] = vloc - except Exception: - pass - - -# ---- LLM & Embeddings helpers ---- - - -def _put_env(key: str, value: str | None) -> None: - if value is None: - return - os.environ[key] = value - - -def _put_session(key: str, value: str | None) -> None: - if st is None: - return - try: - st.session_state[key] = value - except Exception: - pass - - -def update_llm_settings(*, provider: str, values: dict[str, str | None]) -> None: - """Update chat LLM settings from UI into process env and session. - - This function mirrors the environment-variable based configuration consumed by - llm_utils.llm.factory.get_llm(). Only sets provided keys; missing values are left as-is. - """ - provider_norm = (provider or "").lower() - if provider_norm not in { - "openai", - "azure", - "bedrock", - "gemini", - "ollama", - "huggingface", - }: - raise ValueError(f"지원하지 않는 LLM 공급자: {provider}") - - # Core selector - _put_env("LLM_PROVIDER", provider_norm) - _put_session("LLM_PROVIDER", provider_norm) - - # Provider-specific fields (keys exactly as factory expects) - for k, v in (values or {}).items(): - if v is not None: - _put_env(k, v) - _put_session(k, v) - - -def save_llm_profile( - *, - name: str, - provider: str, - values: dict[str, str | None], - note: Optional[str] = None, -) -> None: - """Persist an LLM profile including secrets (explicit per user's request).""" - provider_norm = (provider or "").lower() - stored_fields: Dict[str, str] = {} - for k, v in (values or {}).items(): - if v is None: - continue - stored_fields[k] = str(v) - - reg = get_llm_registry() - # upsert by name - for idx, p in enumerate(reg.profiles): - if p.name == name: - reg.profiles[idx] = LLMProfile( - name=name, provider=provider_norm, fields=stored_fields, note=note - ) - _save_llm_registry(reg) - return - reg.profiles.append( - LLMProfile(name=name, provider=provider_norm, fields=stored_fields, note=note) - ) - _save_llm_registry(reg) - - -def update_embedding_settings(*, provider: str, values: dict[str, str | None]) -> None: - """Update Embeddings settings from UI into process env and session. - - Mirrors env vars consumed by llm_utils.llm.factory.get_embeddings(). - """ - provider_norm = (provider or "").lower() - if provider_norm not in { - "openai", - "azure", - "bedrock", - "gemini", - "ollama", - "huggingface", - }: - raise ValueError(f"지원하지 않는 Embedding 공급자: {provider}") - - _put_env("EMBEDDING_PROVIDER", provider_norm) - _put_session("EMBEDDING_PROVIDER", provider_norm) - - for k, v in (values or {}).items(): - if v is not None: - _put_env(k, v) - _put_session(k, v) - - -def save_embedding_profile( - *, - name: str, - provider: str, - values: dict[str, str | None], - note: Optional[str] = None, -) -> None: - provider_norm = (provider or "").lower() - stored_fields: Dict[str, str] = {} - for k, v in (values or {}).items(): - if v is None: - continue - stored_fields[k] = str(v) - - reg = get_embedding_registry() - for idx, p in enumerate(reg.profiles): - if p.name == name: - reg.profiles[idx] = EmbeddingProfile( - name=name, provider=provider_norm, fields=stored_fields, note=note - ) - _save_embedding_registry(reg) - return - reg.profiles.append( - EmbeddingProfile( - name=name, provider=provider_norm, fields=stored_fields, note=note - ) - ) - _save_embedding_registry(reg) diff --git a/interface/core/config/__init__.py b/interface/core/config/__init__.py new file mode 100644 index 0000000..47c2a63 --- /dev/null +++ b/interface/core/config/__init__.py @@ -0,0 +1,144 @@ +"""config 패키지의 공개 API를 재노출하여 기존 import 호환성을 유지합니다. +모델, 경로/지속성, 레지스트리, 설정 업데이트 유틸을 한 곳에서 제공합니다. +""" + +from .models import ( + Config, + DataHubSource, + VectorDBSource, + DataSourcesRegistry, + DBConnectionProfile, + DBConnectionsRegistry, + LLMProfile, + LLMRegistry, + EmbeddingProfile, + EmbeddingRegistry, +) + +from .settings import ( + DEFAULT_DATAHUB_SERVER, + DEFAULT_VECTORDB_TYPE, + DEFAULT_VECTORDB_LOCATION, + load_config, + _get_session_value, + update_datahub_server, + update_data_source_mode, + update_vectordb_settings, + update_llm_settings, + update_embedding_settings, + update_db_settings, + _put_env, + _put_session, +) + +from .registry_data_sources import ( + get_data_sources_registry, + _save_registry, + add_datahub_source, + update_datahub_source, + delete_datahub_source, + add_vectordb_source, + update_vectordb_source, + delete_vectordb_source, +) + +from .registry_db import ( + get_db_connections_registry, + _save_db_registry, + add_db_connection, + update_db_connection, + delete_db_connection, +) + +from .registry_llm import ( + get_llm_registry, + save_llm_profile, + _save_llm_registry, + get_embedding_registry, + save_embedding_profile, + _save_embedding_registry, +) + +from .paths import ( + _get_registry_file_path, + _get_db_registry_file_path, + _get_llm_registry_file_path, + _get_embedding_registry_file_path, + _ensure_parent_dir, +) + +from .persist import ( + save_registry_to_disk, + save_db_registry_to_disk, + save_llm_registry_to_disk, + save_embedding_registry_to_disk, + load_registry_from_disk, + load_db_registry_from_disk, + load_llm_registry_from_disk, + load_embedding_registry_from_disk, +) + +__all__ = [ + # Models + "Config", + "DataHubSource", + "VectorDBSource", + "DataSourcesRegistry", + "DBConnectionProfile", + "DBConnectionsRegistry", + "LLMProfile", + "LLMRegistry", + "EmbeddingProfile", + "EmbeddingRegistry", + # Defaults + "DEFAULT_DATAHUB_SERVER", + "DEFAULT_VECTORDB_TYPE", + "DEFAULT_VECTORDB_LOCATION", + # Settings APIs + "load_config", + "_get_session_value", + "update_datahub_server", + "update_data_source_mode", + "update_vectordb_settings", + "update_llm_settings", + "update_embedding_settings", + "update_db_settings", + "_put_env", + "_put_session", + # Registries - data sources + "get_data_sources_registry", + "_save_registry", + "add_datahub_source", + "update_datahub_source", + "delete_datahub_source", + "add_vectordb_source", + "update_vectordb_source", + "delete_vectordb_source", + # Registries - db connections + "get_db_connections_registry", + "_save_db_registry", + "add_db_connection", + "update_db_connection", + "delete_db_connection", + # Registries - llm/embedding + "get_llm_registry", + "save_llm_profile", + "_save_llm_registry", + "get_embedding_registry", + "save_embedding_profile", + "_save_embedding_registry", + # Persistence helpers and paths (for backward compatibility) + "_get_registry_file_path", + "_get_db_registry_file_path", + "_get_llm_registry_file_path", + "_get_embedding_registry_file_path", + "_ensure_parent_dir", + "save_registry_to_disk", + "save_db_registry_to_disk", + "save_llm_registry_to_disk", + "save_embedding_registry_to_disk", + "load_registry_from_disk", + "load_db_registry_from_disk", + "load_llm_registry_from_disk", + "load_embedding_registry_from_disk", +] diff --git a/interface/core/config/models.py b/interface/core/config/models.py new file mode 100644 index 0000000..9ec02af --- /dev/null +++ b/interface/core/config/models.py @@ -0,0 +1,83 @@ +"""설정 및 각 레지스트리에서 사용하는 데이터 모델(dataclass) 정의 모듈입니다.""" + +from dataclasses import dataclass, field +from typing import List, Optional, Any, Dict + + +@dataclass +class Config: + datahub_server: str + vectordb_type: str + vectordb_location: str + data_source_mode: str | None = None # "datahub" | "vectordb" | None + + +@dataclass +class DataHubSource: + name: str + url: str + faiss_path: Optional[str] = None + note: Optional[str] = None + + +@dataclass +class VectorDBSource: + name: str + type: str # 'faiss' | 'pgvector' + location: str + collection_prefix: Optional[str] = None + note: Optional[str] = None + + +@dataclass +class DataSourcesRegistry: + datahub: List[DataHubSource] = field(default_factory=list) + vectordb: List[VectorDBSource] = field(default_factory=list) + + +@dataclass +class DBConnectionProfile: + name: str + type: str # 'postgresql' | 'mysql' | 'mariadb' | 'oracle' | 'clickhouse' | 'duckdb' | 'sqlite' | 'databricks' | 'snowflake' | 'trino' + host: Optional[str] = None + port: Optional[int] = None + user: Optional[str] = None + password: Optional[str] = None + database: Optional[str] = None + extra: Optional[Dict[str, Any]] = None # non-secret + note: Optional[str] = None + + +@dataclass +class DBConnectionsRegistry: + connections: List[DBConnectionProfile] = field(default_factory=list) + + +@dataclass +class LLMProfile: + name: str + provider: ( + str # 'openai' | 'azure' | 'bedrock' | 'gemini' | 'ollama' | 'huggingface' + ) + fields: Dict[str, str] = field(default_factory=dict) # includes secrets + note: Optional[str] = None + + +@dataclass +class LLMRegistry: + profiles: List[LLMProfile] = field(default_factory=list) + + +@dataclass +class EmbeddingProfile: + name: str + provider: ( + str # 'openai' | 'azure' | 'bedrock' | 'gemini' | 'ollama' | 'huggingface' + ) + fields: Dict[str, str] = field(default_factory=dict) + note: Optional[str] = None + + +@dataclass +class EmbeddingRegistry: + profiles: List[EmbeddingProfile] = field(default_factory=list) diff --git a/interface/core/config/paths.py b/interface/core/config/paths.py new file mode 100644 index 0000000..38364c2 --- /dev/null +++ b/interface/core/config/paths.py @@ -0,0 +1,38 @@ +"""레지스트리 파일 경로 계산 및 상위 디렉토리 생성 유틸리티를 제공합니다.""" + +import os +from pathlib import Path + + +def _get_registry_file_path() -> Path: + # Allow override via env var, else default to ./config/data_sources.json + override = os.getenv("LANG2SQL_REGISTRY_PATH") + if override: + return Path(override).expanduser().resolve() + return Path(os.getcwd()) / "config" / "data_sources.json" + + +def _get_db_registry_file_path() -> Path: + # Allow override via env var, else default to ./config/db_connections.json + override = os.getenv("LANG2SQL_DB_REGISTRY_PATH") + if override: + return Path(override).expanduser().resolve() + return Path(os.getcwd()) / "config" / "db_connections.json" + + +def _get_llm_registry_file_path() -> Path: + override = os.getenv("LANG2SQL_LLM_REGISTRY_PATH") + if override: + return Path(override).expanduser().resolve() + return Path(os.getcwd()) / "config" / "llm_profiles.json" + + +def _get_embedding_registry_file_path() -> Path: + override = os.getenv("LANG2SQL_EMBEDDING_REGISTRY_PATH") + if override: + return Path(override).expanduser().resolve() + return Path(os.getcwd()) / "config" / "embedding_profiles.json" + + +def _ensure_parent_dir(path: Path) -> None: + path.parent.mkdir(parents=True, exist_ok=True) diff --git a/interface/core/config/persist.py b/interface/core/config/persist.py new file mode 100644 index 0000000..ce33a28 --- /dev/null +++ b/interface/core/config/persist.py @@ -0,0 +1,205 @@ +"""레지스트리 직렬화/역직렬화와 디스크 저장/로드 로직을 제공합니다.""" + +import json +from dataclasses import asdict +from pathlib import Path +from typing import Any, Dict, List + +from .models import ( + DataSourcesRegistry, + DataHubSource, + VectorDBSource, + DBConnectionsRegistry, + DBConnectionProfile, + LLMRegistry, + LLMProfile, + EmbeddingRegistry, + EmbeddingProfile, +) +from .paths import ( + _get_registry_file_path, + _get_db_registry_file_path, + _get_llm_registry_file_path, + _get_embedding_registry_file_path, + _ensure_parent_dir, +) + + +def save_registry_to_disk(registry: DataSourcesRegistry) -> None: + path = _get_registry_file_path() + _ensure_parent_dir(path) + payload = asdict(registry) + with path.open("w", encoding="utf-8") as f: + json.dump(payload, f, ensure_ascii=False, indent=2) + + +def save_db_registry_to_disk(registry: DBConnectionsRegistry) -> None: + path = _get_db_registry_file_path() + _ensure_parent_dir(path) + payload = asdict(registry) + with path.open("w", encoding="utf-8") as f: + json.dump(payload, f, ensure_ascii=False, indent=2) + + +def save_llm_registry_to_disk(registry: LLMRegistry) -> None: + path = _get_llm_registry_file_path() + _ensure_parent_dir(path) + payload = asdict(registry) + with path.open("w", encoding="utf-8") as f: + json.dump(payload, f, ensure_ascii=False, indent=2) + + +def save_embedding_registry_to_disk(registry: EmbeddingRegistry) -> None: + path = _get_embedding_registry_file_path() + _ensure_parent_dir(path) + payload = asdict(registry) + with path.open("w", encoding="utf-8") as f: + json.dump(payload, f, ensure_ascii=False, indent=2) + + +def _parse_datahub_list(items: List[Dict[str, Any]]) -> List[DataHubSource]: + parsed: List[DataHubSource] = [] + for item in items or []: + name = str(item.get("name", "")).strip() + url = str(item.get("url", "")).strip() + faiss_path = item.get("faiss_path") + note = item.get("note") + if not name or not url: + continue + parsed.append( + DataHubSource(name=name, url=url, faiss_path=faiss_path, note=note) + ) + return parsed + + +def _parse_vectordb_list(items: List[Dict[str, Any]]) -> List[VectorDBSource]: + parsed: List[VectorDBSource] = [] + for item in items or []: + name = str(item.get("name", "")).strip() + vtype = str(item.get("type", "")).strip().lower() + location = str(item.get("location", "")).strip() + if not name or not vtype or not location: + continue + collection_prefix = item.get("collection_prefix") + note = item.get("note") + parsed.append( + VectorDBSource( + name=name, + type=vtype, + location=location, + collection_prefix=collection_prefix, + note=note, + ) + ) + return parsed + + +def load_registry_from_disk() -> DataSourcesRegistry: + path = _get_registry_file_path() + if not path.exists(): + return DataSourcesRegistry() + with path.open("r", encoding="utf-8") as f: + data: Dict[str, Any] = json.load(f) + return DataSourcesRegistry( + datahub=_parse_datahub_list(data.get("datahub", [])), + vectordb=_parse_vectordb_list(data.get("vectordb", [])), + ) + + +def _parse_db_conn_list(items: List[Dict[str, Any]]) -> List[DBConnectionProfile]: + parsed: List[DBConnectionProfile] = [] + for item in items or []: + name = str(item.get("name", "")).strip() + db_type = str(item.get("type", "")).strip().lower() + if not name or not db_type: + continue + host = item.get("host") + port = item.get("port") + try: + port = int(port) if port is not None else None + except Exception: + port = None + user = item.get("user") + password = item.get("password") + database = item.get("database") + extra = item.get("extra") or None + note = item.get("note") or None + parsed.append( + DBConnectionProfile( + name=name, + type=db_type, + host=host, + port=port, + user=user, + password=password, + database=database, + extra=extra, + note=note, + ) + ) + return parsed + + +def load_db_registry_from_disk() -> DBConnectionsRegistry: + path = _get_db_registry_file_path() + if not path.exists(): + return DBConnectionsRegistry() + with path.open("r", encoding="utf-8") as f: + data: Dict[str, Any] = json.load(f) + return DBConnectionsRegistry( + connections=_parse_db_conn_list(data.get("connections", [])) + ) + + +def _parse_llm_profiles(items: List[Dict[str, Any]]) -> List[LLMProfile]: + parsed: List[LLMProfile] = [] + for item in items or []: + name = str(item.get("name", "")).strip() + provider = str(item.get("provider", "")).strip().lower() + if not name or not provider: + continue + fields = item.get("fields") or {} + note = item.get("note") or None + if not isinstance(fields, dict): + fields = {} + parsed.append( + LLMProfile(name=name, provider=provider, fields=fields, note=note) + ) + return parsed + + +def load_llm_registry_from_disk() -> LLMRegistry: + path = _get_llm_registry_file_path() + if not path.exists(): + return LLMRegistry() + with path.open("r", encoding="utf-8") as f: + data: Dict[str, Any] = json.load(f) + return LLMRegistry(profiles=_parse_llm_profiles(data.get("profiles", []))) + + +def _parse_embedding_profiles(items: List[Dict[str, Any]]) -> List[EmbeddingProfile]: + parsed: List[EmbeddingProfile] = [] + for item in items or []: + name = str(item.get("name", "")).strip() + provider = str(item.get("provider", "")).strip().lower() + if not name or not provider: + continue + fields = item.get("fields") or {} + note = item.get("note") or None + if not isinstance(fields, dict): + fields = {} + parsed.append( + EmbeddingProfile(name=name, provider=provider, fields=fields, note=note) + ) + return parsed + + +def load_embedding_registry_from_disk() -> EmbeddingRegistry: + path = _get_embedding_registry_file_path() + if not path.exists(): + return EmbeddingRegistry() + with path.open("r", encoding="utf-8") as f: + data: Dict[str, Any] = json.load(f) + return EmbeddingRegistry( + profiles=_parse_embedding_profiles(data.get("profiles", [])) + ) diff --git a/interface/core/config/registry_data_sources.py b/interface/core/config/registry_data_sources.py new file mode 100644 index 0000000..8e9b646 --- /dev/null +++ b/interface/core/config/registry_data_sources.py @@ -0,0 +1,130 @@ +"""DataHub/VectorDB 소스 레지스트리를 세션+디스크에 관리하는 모듈입니다. +get/add/update/delete 연산과 Streamlit 세션 연동을 제공합니다. +""" + +from typing import Optional + +try: + import streamlit as st # type: ignore +except Exception: # pragma: no cover + st = None # type: ignore + +from .models import DataSourcesRegistry, DataHubSource, VectorDBSource +from .persist import ( + load_registry_from_disk, + save_registry_to_disk, +) + + +def get_data_sources_registry() -> DataSourcesRegistry: + if st is not None and "data_sources_registry" in st.session_state: + reg = st.session_state["data_sources_registry"] + return reg # stored as DataSourcesRegistry + # Try load from disk + try: + registry = load_registry_from_disk() + except Exception: + registry = DataSourcesRegistry() + if st is not None: + st.session_state["data_sources_registry"] = registry + return registry + + +def _save_registry(registry: DataSourcesRegistry) -> None: + if st is not None: + st.session_state["data_sources_registry"] = registry + try: + save_registry_to_disk(registry) + except Exception: + # fail-soft; UI will still have session copy + pass + + +def add_datahub_source( + *, name: str, url: str, faiss_path: Optional[str] = None, note: Optional[str] = None +) -> None: + registry = get_data_sources_registry() + if any(s.name == name for s in registry.datahub): + raise ValueError(f"이미 존재하는 DataHub 이름입니다: {name}") + registry.datahub.append( + DataHubSource(name=name, url=url, faiss_path=faiss_path, note=note) + ) + _save_registry(registry) + + +def update_datahub_source( + *, name: str, url: str, faiss_path: Optional[str], note: Optional[str] +) -> None: + registry = get_data_sources_registry() + for idx, s in enumerate(registry.datahub): + if s.name == name: + registry.datahub[idx] = DataHubSource( + name=name, url=url, faiss_path=faiss_path, note=note + ) + _save_registry(registry) + return + raise ValueError(f"존재하지 않는 DataHub 이름입니다: {name}") + + +def delete_datahub_source(*, name: str) -> None: + registry = get_data_sources_registry() + registry.datahub = [s for s in registry.datahub if s.name != name] + _save_registry(registry) + + +def add_vectordb_source( + *, + name: str, + vtype: str, + location: str, + collection_prefix: Optional[str] = None, + note: Optional[str] = None, +) -> None: + vtype = (vtype or "").lower() + if vtype not in ("faiss", "pgvector"): + raise ValueError("VectorDB 타입은 'faiss' 또는 'pgvector'여야 합니다") + registry = get_data_sources_registry() + if any(s.name == name for s in registry.vectordb): + raise ValueError(f"이미 존재하는 VectorDB 이름입니다: {name}") + registry.vectordb.append( + VectorDBSource( + name=name, + type=vtype, + location=location, + collection_prefix=collection_prefix, + note=note, + ) + ) + _save_registry(registry) + + +def update_vectordb_source( + *, + name: str, + vtype: str, + location: str, + collection_prefix: Optional[str], + note: Optional[str], +) -> None: + vtype = (vtype or "").lower() + if vtype not in ("faiss", "pgvector"): + raise ValueError("VectorDB 타입은 'faiss' 또는 'pgvector'여야 합니다") + registry = get_data_sources_registry() + for idx, s in enumerate(registry.vectordb): + if s.name == name: + registry.vectordb[idx] = VectorDBSource( + name=name, + type=vtype, + location=location, + collection_prefix=collection_prefix, + note=note, + ) + _save_registry(registry) + return + raise ValueError(f"존재하지 않는 VectorDB 이름입니다: {name}") + + +def delete_vectordb_source(*, name: str) -> None: + registry = get_data_sources_registry() + registry.vectordb = [s for s in registry.vectordb if s.name != name] + _save_registry(registry) diff --git a/interface/core/config/registry_db.py b/interface/core/config/registry_db.py new file mode 100644 index 0000000..b58d5e7 --- /dev/null +++ b/interface/core/config/registry_db.py @@ -0,0 +1,106 @@ +"""DB 연결 프로파일 레지스트리를 세션+디스크에 관리하는 모듈입니다. +get/add/update/delete 연산과 Streamlit 세션 연동을 제공합니다. +""" + +from typing import Any, Dict, Optional + +try: + import streamlit as st # type: ignore +except Exception: # pragma: no cover + st = None # type: ignore + +from .models import DBConnectionsRegistry, DBConnectionProfile +from .persist import load_db_registry_from_disk, save_db_registry_to_disk + + +def get_db_connections_registry() -> DBConnectionsRegistry: + if st is not None and "db_connections_registry" in st.session_state: + reg = st.session_state["db_connections_registry"] + return reg # stored as DBConnectionsRegistry + try: + registry = load_db_registry_from_disk() + except Exception: + registry = DBConnectionsRegistry() + if st is not None: + st.session_state["db_connections_registry"] = registry + return registry + + +def _save_db_registry(registry: DBConnectionsRegistry) -> None: + if st is not None: + st.session_state["db_connections_registry"] = registry + try: + save_db_registry_to_disk(registry) + except Exception: + # fail-soft; UI will still have session copy + pass + + +def add_db_connection( + *, + name: str, + db_type: str, + host: Optional[str] = None, + port: Optional[int] = None, + user: Optional[str] = None, + password: Optional[str] = None, + database: Optional[str] = None, + extra: Optional[Dict[str, Any]] = None, + note: Optional[str] = None, +) -> None: + db_type_norm = (db_type or "").lower() + registry = get_db_connections_registry() + if any(c.name == name for c in registry.connections): + raise ValueError(f"이미 존재하는 DB 프로파일 이름입니다: {name}") + registry.connections.append( + DBConnectionProfile( + name=name, + type=db_type_norm, + host=host, + port=port, + user=user, + password=password, + database=database, + extra=extra or None, + note=note or None, + ) + ) + _save_db_registry(registry) + + +def update_db_connection( + *, + name: str, + db_type: str, + host: Optional[str], + port: Optional[int], + user: Optional[str], + password: Optional[str], + database: Optional[str], + extra: Optional[Dict[str, Any]], + note: Optional[str], +) -> None: + db_type_norm = (db_type or "").lower() + registry = get_db_connections_registry() + for idx, c in enumerate(registry.connections): + if c.name == name: + registry.connections[idx] = DBConnectionProfile( + name=name, + type=db_type_norm, + host=host, + port=port, + user=user, + password=password, + database=database, + extra=extra or None, + note=note or None, + ) + _save_db_registry(registry) + return + raise ValueError(f"존재하지 않는 DB 프로파일 이름입니다: {name}") + + +def delete_db_connection(*, name: str) -> None: + registry = get_db_connections_registry() + registry.connections = [c for c in registry.connections if c.name != name] + _save_db_registry(registry) diff --git a/interface/core/config/registry_llm.py b/interface/core/config/registry_llm.py new file mode 100644 index 0000000..7f4c17a --- /dev/null +++ b/interface/core/config/registry_llm.py @@ -0,0 +1,114 @@ +"""LLM/Embedding 프로파일 레지스트리를 세션+디스크에 관리하는 모듈입니다. +프로파일 저장(upsert)과 Streamlit 세션 연동을 제공합니다. +""" + +try: + import streamlit as st # type: ignore +except Exception: # pragma: no cover + st = None # type: ignore + +from .models import ( + LLMRegistry, + LLMProfile, + EmbeddingRegistry, + EmbeddingProfile, +) +from .persist import ( + load_llm_registry_from_disk, + save_llm_registry_to_disk, + load_embedding_registry_from_disk, + save_embedding_registry_to_disk, +) + + +def get_llm_registry() -> LLMRegistry: + if st is not None and "llm_registry" in st.session_state: + return st.session_state["llm_registry"] + try: + registry = load_llm_registry_from_disk() + except Exception: + registry = LLMRegistry() + if st is not None: + st.session_state["llm_registry"] = registry + return registry + + +def _save_llm_registry(registry: LLMRegistry) -> None: + if st is not None: + st.session_state["llm_registry"] = registry + try: + save_llm_registry_to_disk(registry) + except Exception: + pass + + +def get_embedding_registry() -> EmbeddingRegistry: + if st is not None and "embedding_registry" in st.session_state: + return st.session_state["embedding_registry"] + try: + registry = load_embedding_registry_from_disk() + except Exception: + registry = EmbeddingRegistry() + if st is not None: + st.session_state["embedding_registry"] = registry + return registry + + +def _save_embedding_registry(registry: EmbeddingRegistry) -> None: + if st is not None: + st.session_state["embedding_registry"] = registry + try: + save_embedding_registry_to_disk(registry) + except Exception: + pass + + +def save_llm_profile( + *, name: str, provider: str, values: dict[str, str | None], note: str | None = None +) -> None: + provider_norm = (provider or "").lower() + stored_fields: dict[str, str] = {} + for k, v in (values or {}).items(): + if v is None: + continue + stored_fields[k] = str(v) + + reg = get_llm_registry() + # upsert by name + for idx, p in enumerate(reg.profiles): + if p.name == name: + reg.profiles[idx] = LLMProfile( + name=name, provider=provider_norm, fields=stored_fields, note=note + ) + _save_llm_registry(reg) + return + reg.profiles.append( + LLMProfile(name=name, provider=provider_norm, fields=stored_fields, note=note) + ) + _save_llm_registry(reg) + + +def save_embedding_profile( + *, name: str, provider: str, values: dict[str, str | None], note: str | None = None +) -> None: + provider_norm = (provider or "").lower() + stored_fields: dict[str, str] = {} + for k, v in (values or {}).items(): + if v is None: + continue + stored_fields[k] = str(v) + + reg = get_embedding_registry() + for idx, p in enumerate(reg.profiles): + if p.name == name: + reg.profiles[idx] = EmbeddingProfile( + name=name, provider=provider_norm, fields=stored_fields, note=note + ) + _save_embedding_registry(reg) + return + reg.profiles.append( + EmbeddingProfile( + name=name, provider=provider_norm, fields=stored_fields, note=note + ) + ) + _save_embedding_registry(reg) diff --git a/interface/core/config/settings.py b/interface/core/config/settings.py new file mode 100644 index 0000000..5e15ba4 --- /dev/null +++ b/interface/core/config/settings.py @@ -0,0 +1,251 @@ +"""런타임 설정 로딩/업데이트 및 세션/환경 변수 반영, 입력값 검증 유틸 포함. +DataHub/VectorDB/DB/LLM/Embedding 관련 설정 업데이트를 제공합니다. +""" + +import os +from typing import Any, Dict, Optional +from pathlib import Path + +try: + import streamlit as st # type: ignore +except Exception: # pragma: no cover - streamlit may not be present in non-UI contexts + st = None # type: ignore + +from llm_utils.tools import set_gms_server + +from .models import Config + + +DEFAULT_DATAHUB_SERVER = "http://localhost:8080" +DEFAULT_VECTORDB_TYPE = os.getenv("VECTORDB_TYPE", "faiss").lower() +DEFAULT_VECTORDB_LOCATION = os.getenv("VECTORDB_LOCATION", "") + + +def _get_session_value(key: str) -> str | None: + if st is None: + return None + try: + if key in st.session_state and st.session_state[key]: + return str(st.session_state[key]) + except Exception: + return None + return None + + +def load_config() -> Config: + """Load configuration with priority: session_state > environment > defaults.""" + datahub = _get_session_value("datahub_server") or os.getenv( + "DATAHUB_SERVER", DEFAULT_DATAHUB_SERVER + ) + mode = _get_session_value("data_source_mode") + + vectordb_type = _get_session_value("vectordb_type") or os.getenv( + "VECTORDB_TYPE", DEFAULT_VECTORDB_TYPE + ) + vectordb_location = _get_session_value("vectordb_location") or os.getenv( + "VECTORDB_LOCATION", DEFAULT_VECTORDB_LOCATION + ) + + return Config( + datahub_server=datahub, + vectordb_type=vectordb_type.lower() if vectordb_type else DEFAULT_VECTORDB_TYPE, + vectordb_location=vectordb_location, + data_source_mode=mode, + ) + + +def update_datahub_server(config: Config, new_url: str) -> None: + """Update DataHub server URL across runtime config, env-aware clients, and session.""" + if not new_url: + return + config.datahub_server = new_url + + # Propagate to underlying tooling/clients + try: + set_gms_server(new_url) + except Exception: + # Fail-soft: UI should surface errors from callers if needed + pass + + # Reflect into session state for immediate UI reuse + if st is not None: + try: + st.session_state["datahub_server"] = new_url + except Exception: + pass + + +def update_data_source_mode(config: Config, mode: str | None) -> None: + """Persist user's data source selection (datahub | vectordb).""" + config.data_source_mode = mode + if st is not None: + try: + st.session_state["data_source_mode"] = mode + except Exception: + pass + + +def _put_env(key: str, value: str | None) -> None: + if value is None: + return + os.environ[key] = value + + +def _put_session(key: str, value: str | None) -> None: + if st is None: + return + try: + st.session_state[key] = value + except Exception: + pass + + +def update_db_settings( + *, + db_type: str, + values: Dict[str, Any] | None, + secrets: Dict[str, Optional[str]] | None = None, +) -> None: + """Update DB settings into process env and session. + + Only non-sensitive values should be passed in values and may come from registry. + Secrets (e.g., PASSWORD, ACCESS_TOKEN) are applied to env/session but never persisted to disk. + """ + db_type_norm = (db_type or "").lower() + if not db_type_norm: + raise ValueError("DB 타입이 비어 있습니다.") + + # Core selector + _put_env("DB_TYPE", db_type_norm) + _put_session("DB_TYPE", db_type_norm) + + prefix = db_type_norm.upper() + + base_keys = ["HOST", "PORT", "USER", "DATABASE"] + for base_key in base_keys: + vk = base_key.lower() + v = (values or {}).get(vk) + if v is None: + continue + _put_env(f"{prefix}_{base_key}", str(v)) + _put_session(f"{prefix}_{base_key}", str(v)) + + # Extras (non-secret) + extra = (values or {}).get("extra") or {} + if isinstance(extra, dict): + for k, v in extra.items(): + if v is None: + continue + _put_env(f"{prefix}_{str(k).upper()}", str(v)) + _put_session(f"{prefix}_{str(k).upper()}", str(v)) + + # Secrets (applied to env+session, never persisted) + for sk, sv in (secrets or {}).items(): + if sv is None: + continue + key_up = str(sk).upper() + _put_env(f"{prefix}_{key_up}", str(sv)) + _put_session(f"{prefix}_{key_up}", str(sv)) + + +def update_vectordb_settings( + config: Config, *, vectordb_type: str, vectordb_location: str | None +) -> None: + """Validate and update VectorDB settings into env and session. + + Basic validation rules follow CLI's behavior: + - vectordb_type must be 'faiss' or 'pgvector' + - if type == 'faiss' and location provided: must be an existing directory + - if type == 'pgvector' and location provided: must start with 'postgresql://' + """ + vtype = (vectordb_type or "").lower() + if vtype not in ("faiss", "pgvector"): + raise ValueError(f"지원하지 않는 VectorDB 타입: {vectordb_type}") + + vloc = vectordb_location or "" + if vloc: + if vtype == "faiss": + path = Path(vloc) + # 신규 경로 허용: 존재하면 디렉토리인지 확인, 없으면 상위 디렉토리 생성 + if path.exists() and not path.is_dir(): + raise ValueError( + f"유효하지 않은 FAISS 디렉토리 경로(파일 경로임): {vloc}" + ) + if not path.exists(): + try: + path.mkdir(parents=True, exist_ok=True) + except Exception as e: + raise ValueError(f"FAISS 경로 생성 실패: {vloc} | {e}") + elif vtype == "pgvector": + if not vloc.startswith("postgresql://"): + raise ValueError("pgvector URL은 'postgresql://'로 시작해야 합니다") + + # Persist to runtime config + config.vectordb_type = vtype + config.vectordb_location = vloc + + # Reflect to process env for downstream modules + os.environ["VECTORDB_TYPE"] = vtype + if vloc: + os.environ["VECTORDB_LOCATION"] = vloc + + # Reflect to session state for UI + if st is not None: + try: + st.session_state["vectordb_type"] = vtype + st.session_state["vectordb_location"] = vloc + except Exception: + pass + + +def update_llm_settings(*, provider: str, values: dict[str, str | None]) -> None: + """Update chat LLM settings from UI into process env and session. + + This function mirrors the environment-variable based configuration consumed by + llm_utils.llm.factory.get_llm(). Only sets provided keys; missing values are left as-is. + """ + provider_norm = (provider or "").lower() + if provider_norm not in { + "openai", + "azure", + "bedrock", + "gemini", + "ollama", + "huggingface", + }: + raise ValueError(f"지원하지 않는 LLM 공급자: {provider}") + + # Core selector + _put_env("LLM_PROVIDER", provider_norm) + _put_session("LLM_PROVIDER", provider_norm) + + # Provider-specific fields (keys exactly as factory expects) + for k, v in (values or {}).items(): + if v is not None: + _put_env(k, v) + _put_session(k, v) + + +def update_embedding_settings(*, provider: str, values: dict[str, str | None]) -> None: + """Update Embeddings settings from UI into process env and session. + + Mirrors env vars consumed by llm_utils.llm.factory.get_embeddings(). + """ + provider_norm = (provider or "").lower() + if provider_norm not in { + "openai", + "azure", + "bedrock", + "gemini", + "ollama", + "huggingface", + }: + raise ValueError(f"지원하지 않는 Embedding 공급자: {provider}") + + _put_env("EMBEDDING_PROVIDER", provider_norm) + _put_session("EMBEDDING_PROVIDER", provider_norm) + + for k, v in (values or {}).items(): + if v is not None: + _put_env(k, v) + _put_session(k, v) From 97a33072bda5b5583d87009c616bf42ed804a69c Mon Sep 17 00:00:00 2001 From: ehddnr301 Date: Sat, 4 Oct 2025 22:42:09 +0900 Subject: [PATCH 08/11] =?UTF-8?q?=EB=B6=88=ED=95=84=EC=9A=94=ED=95=9C=20?= =?UTF-8?q?=EC=B2=B4=ED=81=AC=EB=B0=95=EC=8A=A4=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- interface/app_pages/components/data_source_selector.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/interface/app_pages/components/data_source_selector.py b/interface/app_pages/components/data_source_selector.py index 8aff30e..b32cf3f 100644 --- a/interface/app_pages/components/data_source_selector.py +++ b/interface/app_pages/components/data_source_selector.py @@ -16,11 +16,6 @@ def render_sidebar_data_source_selector(config=None) -> None: registry = get_data_sources_registry() st.sidebar.markdown("### 데이터 소스") - enable_data_source = st.sidebar.checkbox( - "데이터 소스 적용", value=True, key="enable_data_source" - ) - if not enable_data_source: - return mode_index = 0 if (config.data_source_mode or "datahub").lower() == "datahub" else 1 selected_mode = st.sidebar.radio( From 02c51493e0f352b3ce47412730aa4c88d7e89f71 Mon Sep 17 00:00:00 2001 From: ehddnr301 Date: Sat, 4 Oct 2025 22:51:58 +0900 Subject: [PATCH 09/11] =?UTF-8?q?sidebar=5Fcomponent=EB=A1=9C=20=EB=94=94?= =?UTF-8?q?=EB=A0=89=ED=86=A0=EB=A6=AC=EB=AA=85=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- interface/app_pages/lang2sql.py | 21 +++++++++++-------- .../app_pages/sidebar_components/__init__.py | 11 ++++++++++ .../data_source_selector.py | 0 .../db_selector.py | 0 .../embedding_selector.py | 0 .../llm_selector.py | 0 6 files changed, 23 insertions(+), 9 deletions(-) create mode 100644 interface/app_pages/sidebar_components/__init__.py rename interface/app_pages/{components => sidebar_components}/data_source_selector.py (100%) rename interface/app_pages/{components => sidebar_components}/db_selector.py (100%) rename interface/app_pages/{components => sidebar_components}/embedding_selector.py (100%) rename interface/app_pages/{components => sidebar_components}/llm_selector.py (100%) diff --git a/interface/app_pages/lang2sql.py b/interface/app_pages/lang2sql.py index 6252961..0c96156 100644 --- a/interface/app_pages/lang2sql.py +++ b/interface/app_pages/lang2sql.py @@ -21,14 +21,13 @@ from interface.core.result_renderer import display_result from interface.core.session_utils import init_graph from interface.core.config import load_config -from interface.app_pages.components.data_source_selector import ( +from interface.app_pages.sidebar_components import ( render_sidebar_data_source_selector, -) -from interface.app_pages.components.llm_selector import render_sidebar_llm_selector -from interface.app_pages.components.embedding_selector import ( + render_sidebar_llm_selector, render_sidebar_embedding_selector, + render_sidebar_db_selector, ) -from interface.app_pages.components.db_selector import render_sidebar_db_selector + TITLE = "Lang2SQL" DEFAULT_QUERY = "고객 데이터를 기반으로 유니크한 유저 수를 카운트하는 쿼리" @@ -49,9 +48,17 @@ config = load_config() render_sidebar_data_source_selector(config) +st.sidebar.divider() render_sidebar_llm_selector() +st.sidebar.divider() render_sidebar_embedding_selector() +st.sidebar.divider() render_sidebar_db_selector() +st.sidebar.divider() + +st.sidebar.title("Output Settings") +for key, label in SIDEBAR_OPTIONS.items(): + st.sidebar.checkbox(label, value=True, key=key) st.sidebar.markdown("### 워크플로우 선택") use_enriched = st.sidebar.checkbox( @@ -128,10 +135,6 @@ ) user_top_n = st.slider("검색할 테이블 정보 개수:", min_value=1, max_value=20, value=5) -st.sidebar.title("Output Settings") -for key, label in SIDEBAR_OPTIONS.items(): - st.sidebar.checkbox(label, value=True, key=key) - if st.button("쿼리 실행"): res = run_lang2sql( query=user_query, diff --git a/interface/app_pages/sidebar_components/__init__.py b/interface/app_pages/sidebar_components/__init__.py new file mode 100644 index 0000000..36a371b --- /dev/null +++ b/interface/app_pages/sidebar_components/__init__.py @@ -0,0 +1,11 @@ +from .data_source_selector import render_sidebar_data_source_selector +from .llm_selector import render_sidebar_llm_selector +from .embedding_selector import render_sidebar_embedding_selector +from .db_selector import render_sidebar_db_selector + +__all__ = [ + "render_sidebar_data_source_selector", + "render_sidebar_llm_selector", + "render_sidebar_embedding_selector", + "render_sidebar_db_selector", +] diff --git a/interface/app_pages/components/data_source_selector.py b/interface/app_pages/sidebar_components/data_source_selector.py similarity index 100% rename from interface/app_pages/components/data_source_selector.py rename to interface/app_pages/sidebar_components/data_source_selector.py diff --git a/interface/app_pages/components/db_selector.py b/interface/app_pages/sidebar_components/db_selector.py similarity index 100% rename from interface/app_pages/components/db_selector.py rename to interface/app_pages/sidebar_components/db_selector.py diff --git a/interface/app_pages/components/embedding_selector.py b/interface/app_pages/sidebar_components/embedding_selector.py similarity index 100% rename from interface/app_pages/components/embedding_selector.py rename to interface/app_pages/sidebar_components/embedding_selector.py diff --git a/interface/app_pages/components/llm_selector.py b/interface/app_pages/sidebar_components/llm_selector.py similarity index 100% rename from interface/app_pages/components/llm_selector.py rename to interface/app_pages/sidebar_components/llm_selector.py From f25e897a20273e82fe137ee79711e69c2e52cead Mon Sep 17 00:00:00 2001 From: ehddnr301 Date: Sun, 5 Oct 2025 10:17:13 +0900 Subject: [PATCH 10/11] =?UTF-8?q?=ED=95=A8=EC=88=98=EB=AA=85=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=20=EB=B0=8F=20=EB=B6=88=ED=95=84=EC=9A=94=ED=95=9C=20?= =?UTF-8?q?import=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- interface/core/config/__init__.py | 41 ++++++++----------------------- interface/core/config/paths.py | 10 ++++---- interface/core/config/persist.py | 34 ++++++++++++------------- 3 files changed, 32 insertions(+), 53 deletions(-) diff --git a/interface/core/config/__init__.py b/interface/core/config/__init__.py index 47c2a63..e316c17 100644 --- a/interface/core/config/__init__.py +++ b/interface/core/config/__init__.py @@ -16,24 +16,17 @@ ) from .settings import ( - DEFAULT_DATAHUB_SERVER, - DEFAULT_VECTORDB_TYPE, - DEFAULT_VECTORDB_LOCATION, load_config, - _get_session_value, update_datahub_server, update_data_source_mode, update_vectordb_settings, update_llm_settings, update_embedding_settings, update_db_settings, - _put_env, - _put_session, ) from .registry_data_sources import ( get_data_sources_registry, - _save_registry, add_datahub_source, update_datahub_source, delete_datahub_source, @@ -44,7 +37,6 @@ from .registry_db import ( get_db_connections_registry, - _save_db_registry, add_db_connection, update_db_connection, delete_db_connection, @@ -53,18 +45,16 @@ from .registry_llm import ( get_llm_registry, save_llm_profile, - _save_llm_registry, get_embedding_registry, save_embedding_profile, - _save_embedding_registry, ) from .paths import ( - _get_registry_file_path, - _get_db_registry_file_path, - _get_llm_registry_file_path, - _get_embedding_registry_file_path, - _ensure_parent_dir, + get_registry_file_path, + get_db_registry_file_path, + get_llm_registry_file_path, + get_embedding_registry_file_path, + ensure_parent_dir, ) from .persist import ( @@ -90,24 +80,16 @@ "LLMRegistry", "EmbeddingProfile", "EmbeddingRegistry", - # Defaults - "DEFAULT_DATAHUB_SERVER", - "DEFAULT_VECTORDB_TYPE", - "DEFAULT_VECTORDB_LOCATION", # Settings APIs "load_config", - "_get_session_value", "update_datahub_server", "update_data_source_mode", "update_vectordb_settings", "update_llm_settings", "update_embedding_settings", "update_db_settings", - "_put_env", - "_put_session", # Registries - data sources "get_data_sources_registry", - "_save_registry", "add_datahub_source", "update_datahub_source", "delete_datahub_source", @@ -116,23 +98,20 @@ "delete_vectordb_source", # Registries - db connections "get_db_connections_registry", - "_save_db_registry", "add_db_connection", "update_db_connection", "delete_db_connection", # Registries - llm/embedding "get_llm_registry", "save_llm_profile", - "_save_llm_registry", "get_embedding_registry", "save_embedding_profile", - "_save_embedding_registry", # Persistence helpers and paths (for backward compatibility) - "_get_registry_file_path", - "_get_db_registry_file_path", - "_get_llm_registry_file_path", - "_get_embedding_registry_file_path", - "_ensure_parent_dir", + "get_registry_file_path", + "get_db_registry_file_path", + "get_llm_registry_file_path", + "get_embedding_registry_file_path", + "ensure_parent_dir", "save_registry_to_disk", "save_db_registry_to_disk", "save_llm_registry_to_disk", diff --git a/interface/core/config/paths.py b/interface/core/config/paths.py index 38364c2..668f052 100644 --- a/interface/core/config/paths.py +++ b/interface/core/config/paths.py @@ -4,7 +4,7 @@ from pathlib import Path -def _get_registry_file_path() -> Path: +def get_registry_file_path() -> Path: # Allow override via env var, else default to ./config/data_sources.json override = os.getenv("LANG2SQL_REGISTRY_PATH") if override: @@ -12,7 +12,7 @@ def _get_registry_file_path() -> Path: return Path(os.getcwd()) / "config" / "data_sources.json" -def _get_db_registry_file_path() -> Path: +def get_db_registry_file_path() -> Path: # Allow override via env var, else default to ./config/db_connections.json override = os.getenv("LANG2SQL_DB_REGISTRY_PATH") if override: @@ -20,19 +20,19 @@ def _get_db_registry_file_path() -> Path: return Path(os.getcwd()) / "config" / "db_connections.json" -def _get_llm_registry_file_path() -> Path: +def get_llm_registry_file_path() -> Path: override = os.getenv("LANG2SQL_LLM_REGISTRY_PATH") if override: return Path(override).expanduser().resolve() return Path(os.getcwd()) / "config" / "llm_profiles.json" -def _get_embedding_registry_file_path() -> Path: +def get_embedding_registry_file_path() -> Path: override = os.getenv("LANG2SQL_EMBEDDING_REGISTRY_PATH") if override: return Path(override).expanduser().resolve() return Path(os.getcwd()) / "config" / "embedding_profiles.json" -def _ensure_parent_dir(path: Path) -> None: +def ensure_parent_dir(path: Path) -> None: path.parent.mkdir(parents=True, exist_ok=True) diff --git a/interface/core/config/persist.py b/interface/core/config/persist.py index ce33a28..81ba144 100644 --- a/interface/core/config/persist.py +++ b/interface/core/config/persist.py @@ -17,41 +17,41 @@ EmbeddingProfile, ) from .paths import ( - _get_registry_file_path, - _get_db_registry_file_path, - _get_llm_registry_file_path, - _get_embedding_registry_file_path, - _ensure_parent_dir, + get_registry_file_path, + get_db_registry_file_path, + get_llm_registry_file_path, + get_embedding_registry_file_path, + ensure_parent_dir, ) def save_registry_to_disk(registry: DataSourcesRegistry) -> None: - path = _get_registry_file_path() - _ensure_parent_dir(path) + path = get_registry_file_path() + ensure_parent_dir(path) payload = asdict(registry) with path.open("w", encoding="utf-8") as f: json.dump(payload, f, ensure_ascii=False, indent=2) def save_db_registry_to_disk(registry: DBConnectionsRegistry) -> None: - path = _get_db_registry_file_path() - _ensure_parent_dir(path) + path = get_db_registry_file_path() + ensure_parent_dir(path) payload = asdict(registry) with path.open("w", encoding="utf-8") as f: json.dump(payload, f, ensure_ascii=False, indent=2) def save_llm_registry_to_disk(registry: LLMRegistry) -> None: - path = _get_llm_registry_file_path() - _ensure_parent_dir(path) + path = get_llm_registry_file_path() + ensure_parent_dir(path) payload = asdict(registry) with path.open("w", encoding="utf-8") as f: json.dump(payload, f, ensure_ascii=False, indent=2) def save_embedding_registry_to_disk(registry: EmbeddingRegistry) -> None: - path = _get_embedding_registry_file_path() - _ensure_parent_dir(path) + path = get_embedding_registry_file_path() + ensure_parent_dir(path) payload = asdict(registry) with path.open("w", encoding="utf-8") as f: json.dump(payload, f, ensure_ascii=False, indent=2) @@ -95,7 +95,7 @@ def _parse_vectordb_list(items: List[Dict[str, Any]]) -> List[VectorDBSource]: def load_registry_from_disk() -> DataSourcesRegistry: - path = _get_registry_file_path() + path = get_registry_file_path() if not path.exists(): return DataSourcesRegistry() with path.open("r", encoding="utf-8") as f: @@ -141,7 +141,7 @@ def _parse_db_conn_list(items: List[Dict[str, Any]]) -> List[DBConnectionProfile def load_db_registry_from_disk() -> DBConnectionsRegistry: - path = _get_db_registry_file_path() + path = get_db_registry_file_path() if not path.exists(): return DBConnectionsRegistry() with path.open("r", encoding="utf-8") as f: @@ -169,7 +169,7 @@ def _parse_llm_profiles(items: List[Dict[str, Any]]) -> List[LLMProfile]: def load_llm_registry_from_disk() -> LLMRegistry: - path = _get_llm_registry_file_path() + path = get_llm_registry_file_path() if not path.exists(): return LLMRegistry() with path.open("r", encoding="utf-8") as f: @@ -195,7 +195,7 @@ def _parse_embedding_profiles(items: List[Dict[str, Any]]) -> List[EmbeddingProf def load_embedding_registry_from_disk() -> EmbeddingRegistry: - path = _get_embedding_registry_file_path() + path = get_embedding_registry_file_path() if not path.exists(): return EmbeddingRegistry() with path.open("r", encoding="utf-8") as f: From 4f47b581a3c2c39ad12f2c52b5d58e574674962f Mon Sep 17 00:00:00 2001 From: ehddnr301 Date: Sun, 5 Oct 2025 10:23:34 +0900 Subject: [PATCH 11/11] =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=A7=80=20=EC=95=88?= =?UTF-8?q?=EB=82=B4=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- interface/app_pages/home.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/interface/app_pages/home.py b/interface/app_pages/home.py index c876e3d..856ee34 100644 --- a/interface/app_pages/home.py +++ b/interface/app_pages/home.py @@ -19,7 +19,8 @@ #### 사용 방법 1. 왼쪽 메뉴에서 원하는 기능 페이지를 선택하세요. 2. **🔍 Lang2SQL**: 자연어 → SQL 변환 및 결과 분석 - 3. **📊 그래프 빌더**: 데이터 시각화를 위한 차트 구성 + 3. **📊 그래프 빌더**: LangGraph 실행 순서를 프리셋/커스텀으로 구성하고 세션에 적용 + 4. **⚙️ 설정**: 데이터 소스, LLM, DB 연결 등 환경 설정 """ )