diff --git a/cli/__init__.py b/cli/__init__.py index 52b7c87..a917ae9 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", @@ -61,60 +57,57 @@ ) @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, - datahub_server: str, + datahub_server: str | None, run_streamlit: bool, 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 엔트리포인트. - 환경 변수 및 VectorDB 설정 초기화 - - GMS 서버 연결 및 헬스체크 - 필요 시 Streamlit 애플리케이션 실행 """ 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) 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", + ) + + # 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) 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 연결 등 환경 설정 """ ) diff --git a/interface/app_pages/lang2sql.py b/interface/app_pages/lang2sql.py index b24b362..0c96156 100644 --- a/interface/app_pages/lang2sql.py +++ b/interface/app_pages/lang2sql.py @@ -20,6 +20,14 @@ 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.sidebar_components import ( + render_sidebar_data_source_selector, + render_sidebar_llm_selector, + render_sidebar_embedding_selector, + render_sidebar_db_selector, +) + TITLE = "Lang2SQL" DEFAULT_QUERY = "고객 데이터를 기반으로 유니크한 유저 수를 카운트하는 쿼리" @@ -37,6 +45,21 @@ st.title(TITLE) +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( "프로파일 추출 & 컨텍스트 보강 워크플로우 사용", value=False @@ -55,6 +78,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: @@ -110,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/settings.py b/interface/app_pages/settings.py new file mode 100644 index 0000000..21e693e --- /dev/null +++ b/interface/app_pages/settings.py @@ -0,0 +1,31 @@ +""" +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, +) +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("⚙️ 설정") + +config = load_config() + +tabs = st.tabs(["데이터 소스", "LLM", "DB"]) + +with tabs[0]: + render_data_source_section(config) + +with tabs[1]: + render_llm_section(config) + +with tabs[2]: + render_db_section() + +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..2f63881 --- /dev/null +++ b/interface/app_pages/settings_sections/data_source_section.py @@ -0,0 +1,321 @@ +import streamlit as st +from interface.core.config import ( + Config, + load_config, + 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: + mode = config.data_source_mode + ready_msgs = [] + + if mode == "datahub": + last_health = st.session_state.get("datahub_last_health") + if last_health is True: + st.success(f"데이터 소스 준비됨: DataHub ({config.datahub_server})") + 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) + 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() + + registry = get_data_sources_registry() + + if selected == "datahub": + with st.container(border=True): + 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="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="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_vectordb_settings( + config, + vectordb_type=new_type, + vectordb_location=new_location, + ) + 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() + + st.divider() + st.write("VectorDB 추가") + vdb_name = st.text_input("이름", key="vdb_name") + vdb_type = st.selectbox( + "타입", options=["faiss", "pgvector"], key="vdb_type" + ) + vdb_loc_placeholder = ( + "FAISS 디렉토리 경로 (예: ./dev/table_info_db)" + if vdb_type == "faiss" + else "pgvector 연결 문자열 (postgresql://user:pass@host:port/db)" + ) + 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_new"): + try: + update_vectordb_settings( + config, + vectordb_type=vdb_type, + vectordb_location=vdb_location, + ) + st.success("설정이 유효합니다.") + except Exception as e: + st.error(f"검증 실패: {e}") + with cols[1]: + if st.button("추가", key="vdb_add"): + try: + 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}") 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 new file mode 100644 index 0000000..f5fe59f --- /dev/null +++ b/interface/app_pages/settings_sections/llm_section.py @@ -0,0 +1,274 @@ +import os +import streamlit as st + +from interface.core.config import ( + update_llm_settings, + update_embedding_settings, + Config, + load_config, + save_llm_profile, + get_llm_registry, + save_embedding_profile, + get_embedding_registry, +) + + +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] = {} + 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: + values[env_key] = st.text_input( + label, value=prefill, type="password", key=f"llm_{env_key}" + ) + else: + 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() + + 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: + 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}") + + # 저장된 프로파일 미리보기 + 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**") + 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() + + 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: + 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}") + 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/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/sidebar_components/data_source_selector.py b/interface/app_pages/sidebar_components/data_source_selector.py new file mode 100644 index 0000000..b32cf3f --- /dev/null +++ b/interface/app_pages/sidebar_components/data_source_selector.py @@ -0,0 +1,80 @@ +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("### 데이터 소스") + + 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/sidebar_components/db_selector.py b/interface/app_pages/sidebar_components/db_selector.py new file mode 100644 index 0000000..4f9bff0 --- /dev/null +++ b/interface/app_pages/sidebar_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/sidebar_components/embedding_selector.py b/interface/app_pages/sidebar_components/embedding_selector.py new file mode 100644 index 0000000..c01f50c --- /dev/null +++ b/interface/app_pages/sidebar_components/embedding_selector.py @@ -0,0 +1,81 @@ +import os +import streamlit as st + +from interface.core.config import ( + update_embedding_settings, + get_embedding_registry, +) + + +def render_sidebar_embedding_selector() -> None: + st.sidebar.markdown("### Embeddings 선택") + + 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() + 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", + ) + + 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=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}") diff --git a/interface/app_pages/sidebar_components/llm_selector.py b/interface/app_pages/sidebar_components/llm_selector.py new file mode 100644 index 0000000..b8260e0 --- /dev/null +++ b/interface/app_pages/sidebar_components/llm_selector.py @@ -0,0 +1,77 @@ +import os +import streamlit as st + +from interface.core.config import ( + update_llm_settings, + get_llm_registry, +) + + +def render_sidebar_llm_selector() -> None: + st.sidebar.markdown("### LLM 선택") + + 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() + 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 st.sidebar.button("적용", key="sidebar_apply_llm_profile"): + try: + # 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}") + + # Embeddings 관련 UI는 embedding_selector.py에서 처리 diff --git a/interface/core/config/__init__.py b/interface/core/config/__init__.py new file mode 100644 index 0000000..e316c17 --- /dev/null +++ b/interface/core/config/__init__.py @@ -0,0 +1,123 @@ +"""config 패키지의 공개 API를 재노출하여 기존 import 호환성을 유지합니다. +모델, 경로/지속성, 레지스트리, 설정 업데이트 유틸을 한 곳에서 제공합니다. +""" + +from .models import ( + Config, + DataHubSource, + VectorDBSource, + DataSourcesRegistry, + DBConnectionProfile, + DBConnectionsRegistry, + LLMProfile, + LLMRegistry, + EmbeddingProfile, + EmbeddingRegistry, +) + +from .settings import ( + load_config, + update_datahub_server, + update_data_source_mode, + update_vectordb_settings, + update_llm_settings, + update_embedding_settings, + update_db_settings, +) + +from .registry_data_sources import ( + get_data_sources_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, + add_db_connection, + update_db_connection, + delete_db_connection, +) + +from .registry_llm import ( + get_llm_registry, + save_llm_profile, + get_embedding_registry, + save_embedding_profile, +) + +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", + # Settings APIs + "load_config", + "update_datahub_server", + "update_data_source_mode", + "update_vectordb_settings", + "update_llm_settings", + "update_embedding_settings", + "update_db_settings", + # Registries - data sources + "get_data_sources_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", + "add_db_connection", + "update_db_connection", + "delete_db_connection", + # Registries - llm/embedding + "get_llm_registry", + "save_llm_profile", + "get_embedding_registry", + "save_embedding_profile", + # 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..668f052 --- /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..81ba144 --- /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) 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="⚙️ 설정"), ]