From ff6e4377b6fe0da0ea6f65d336ffe24287e1df27 Mon Sep 17 00:00:00 2001 From: CheckerBoard Date: Sat, 16 Aug 2025 23:02:41 +0900 Subject: [PATCH 01/30] =?UTF-8?q?fix:=20=ED=8F=AC=ED=8A=B8=EB=B2=88?= =?UTF-8?q?=ED=98=B8=20=EA=B3=A0=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/src/main.py b/src/main.py index b25548d..94fc27a 100644 --- a/src/main.py +++ b/src/main.py @@ -6,12 +6,12 @@ from fastapi import FastAPI from api.v1.endpoints import chat, annotator -def find_free_port(): - """사용 가능한 비어있는 포트를 찾는 함수""" - with closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as s: - s.bind(('', 0)) - s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) - return s.getsockname()[1] +# def find_free_port(): +# """사용 가능한 비어있는 포트를 찾는 함수""" +# with closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as s: +# s.bind(('', 0)) +# s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) +# return s.getsockname()[1] # FastAPI 앱 인스턴스 생성 app = FastAPI( @@ -41,7 +41,8 @@ def health_check(): if __name__ == "__main__": # 1. 비어있는 포트 동적 할당 - free_port = find_free_port() + # free_port = find_free_port() + free_port = 35816 # 포트 번호 고정 # 2. 할당된 포트 번호를 콘솔에 특정 형식으로 출력 print(f"PYTHON_SERVER_PORT:{free_port}") From da56f3d44e1564624da1fae950c3ee89301ff81b Mon Sep 17 00:00:00 2001 From: CheckerBoard Date: Sun, 17 Aug 2025 17:59:15 +0900 Subject: [PATCH 02/30] =?UTF-8?q?refator:=20sql=5Fagent=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=8A=A4=ED=94=8C=EB=A6=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/agents/__init__.py | 6 + src/agents/sql_agent/__init__.py | 27 +++ src/agents/sql_agent/edges.py | 51 +++++ src/agents/sql_agent/exceptions.py | 31 +++ src/agents/sql_agent/graph.py | 122 ++++++++++++ src/agents/sql_agent/nodes.py | 298 +++++++++++++++++++++++++++++ src/agents/sql_agent/state.py | 30 +++ src/agents/sql_agent_graph.py | 285 --------------------------- 8 files changed, 565 insertions(+), 285 deletions(-) create mode 100644 src/agents/__init__.py create mode 100644 src/agents/sql_agent/__init__.py create mode 100644 src/agents/sql_agent/edges.py create mode 100644 src/agents/sql_agent/exceptions.py create mode 100644 src/agents/sql_agent/graph.py create mode 100644 src/agents/sql_agent/nodes.py create mode 100644 src/agents/sql_agent/state.py delete mode 100644 src/agents/sql_agent_graph.py diff --git a/src/agents/__init__.py b/src/agents/__init__.py new file mode 100644 index 0000000..f677fbe --- /dev/null +++ b/src/agents/__init__.py @@ -0,0 +1,6 @@ +""" +에이전트 루트 패키지 +""" + + + diff --git a/src/agents/sql_agent/__init__.py b/src/agents/sql_agent/__init__.py new file mode 100644 index 0000000..cd71571 --- /dev/null +++ b/src/agents/sql_agent/__init__.py @@ -0,0 +1,27 @@ +# src/agents/sql_agent/__init__.py + +from .state import SqlAgentState +from .nodes import SqlAgentNodes +from .edges import SqlAgentEdges +from .graph import SqlAgentGraph +from .exceptions import ( + SqlAgentException, + ValidationException, + ExecutionException, + DatabaseConnectionException, + LLMProviderException, + MaxRetryExceededException +) + +__all__ = [ + 'SqlAgentState', + 'SqlAgentNodes', + 'SqlAgentEdges', + 'SqlAgentGraph', + 'SqlAgentException', + 'ValidationException', + 'ExecutionException', + 'DatabaseConnectionException', + 'LLMProviderException', + 'MaxRetryExceededException' +] diff --git a/src/agents/sql_agent/edges.py b/src/agents/sql_agent/edges.py new file mode 100644 index 0000000..9a24fe3 --- /dev/null +++ b/src/agents/sql_agent/edges.py @@ -0,0 +1,51 @@ +# src/agents/sql_agent/edges.py + +from .state import SqlAgentState + +# 상수 정의 +MAX_ERROR_COUNT = 3 + +class SqlAgentEdges: + """SQL Agent의 모든 엣지 로직을 담당하는 클래스""" + + @staticmethod + def route_after_intent_classification(state: SqlAgentState) -> str: + """의도 분류 결과에 따라 라우팅을 결정합니다.""" + if state['intent'] == "SQL": + print("--- 의도: SQL 관련 질문 ---") + return "db_classifier" + print("--- 의도: SQL과 관련 없는 질문 ---") + return "unsupported_question" + + @staticmethod + def should_execute_sql(state: SqlAgentState) -> str: + """SQL 검증 결과에 따라 다음 단계를 결정합니다.""" + validation_error_count = state.get("validation_error_count", 0) + + if validation_error_count >= MAX_ERROR_COUNT: + print(f"--- 검증 실패 {MAX_ERROR_COUNT}회 초과: 답변 생성으로 이동 ---") + return "synthesize_failure" + + if state.get("validation_error"): + print(f"--- 검증 실패 {validation_error_count}회: SQL 재생성 ---") + return "regenerate" + + print("--- 검증 성공: SQL 실행 ---") + return "execute" + + @staticmethod + def should_retry_or_respond(state: SqlAgentState) -> str: + """SQL 실행 결과에 따라 다음 단계를 결정합니다.""" + execution_error_count = state.get("execution_error_count", 0) + execution_result = state.get("execution_result", "") + + if execution_error_count >= MAX_ERROR_COUNT: + print(f"--- 실행 실패 {MAX_ERROR_COUNT}회 초과: 답변 생성으로 이동 ---") + return "synthesize_failure" + + if "오류" in execution_result: + print(f"--- 실행 실패 {execution_error_count}회: SQL 재생성 ---") + return "regenerate" + + print("--- 실행 성공: 최종 답변 생성 ---") + return "synthesize_success" diff --git a/src/agents/sql_agent/exceptions.py b/src/agents/sql_agent/exceptions.py new file mode 100644 index 0000000..0e0171a --- /dev/null +++ b/src/agents/sql_agent/exceptions.py @@ -0,0 +1,31 @@ +# src/agents/sql_agent/exceptions.py + +class SqlAgentException(Exception): + """SQL Agent 관련 기본 예외 클래스""" + pass + +class ValidationException(SqlAgentException): + """SQL 검증 실패 예외""" + def __init__(self, message: str, error_count: int = 0): + super().__init__(message) + self.error_count = error_count + +class ExecutionException(SqlAgentException): + """SQL 실행 실패 예외""" + def __init__(self, message: str, error_count: int = 0): + super().__init__(message) + self.error_count = error_count + +class DatabaseConnectionException(SqlAgentException): + """데이터베이스 연결 실패 예외""" + pass + +class LLMProviderException(SqlAgentException): + """LLM 제공자 관련 예외""" + pass + +class MaxRetryExceededException(SqlAgentException): + """최대 재시도 횟수 초과 예외""" + def __init__(self, message: str, max_retries: int): + super().__init__(f"{message} (최대 재시도 {max_retries}회 초과)") + self.max_retries = max_retries diff --git a/src/agents/sql_agent/graph.py b/src/agents/sql_agent/graph.py new file mode 100644 index 0000000..c69341e --- /dev/null +++ b/src/agents/sql_agent/graph.py @@ -0,0 +1,122 @@ +# src/agents/sql_agent/graph.py + +from langgraph.graph import StateGraph, END +from core.providers.llm_provider import LLMProvider +from services.database.database_service import DatabaseService +from .state import SqlAgentState +from .nodes import SqlAgentNodes +from .edges import SqlAgentEdges + +class SqlAgentGraph: + """SQL Agent 그래프를 구성하고 관리하는 클래스""" + + def __init__(self, llm_provider: LLMProvider, database_service: DatabaseService): + self.llm_provider = llm_provider + self.database_service = database_service + self.nodes = SqlAgentNodes(llm_provider, database_service) + self.edges = SqlAgentEdges() + self._graph = None + + def create_graph(self) -> StateGraph: + """SQL Agent 그래프를 생성하고 구성합니다.""" + if self._graph is not None: + return self._graph + + graph = StateGraph(SqlAgentState) + + # 노드 추가 + self._add_nodes(graph) + + # 엣지 추가 + self._add_edges(graph) + + # 진입점 설정 + graph.set_entry_point("intent_classifier") + + # 그래프 컴파일 + self._graph = graph.compile() + return self._graph + + def _add_nodes(self, graph: StateGraph): + """그래프에 모든 노드를 추가합니다.""" + graph.add_node("intent_classifier", self.nodes.intent_classifier_node) + graph.add_node("db_classifier", self.nodes.db_classifier_node) + graph.add_node("unsupported_question", self.nodes.unsupported_question_node) + graph.add_node("sql_generator", self.nodes.sql_generator_node) + graph.add_node("sql_validator", self.nodes.sql_validator_node) + graph.add_node("sql_executor", self.nodes.sql_executor_node) + graph.add_node("response_synthesizer", self.nodes.response_synthesizer_node) + + def _add_edges(self, graph: StateGraph): + """그래프에 모든 엣지를 추가합니다.""" + # 의도 분류 후 조건부 라우팅 + graph.add_conditional_edges( + "intent_classifier", + self.edges.route_after_intent_classification, + { + "db_classifier": "db_classifier", + "unsupported_question": "unsupported_question" + } + ) + + # 지원되지 않는 질문 처리 후 종료 + graph.add_edge("unsupported_question", END) + + # DB 분류 후 SQL 생성으로 이동 + graph.add_edge("db_classifier", "sql_generator") + + # SQL 생성 후 검증으로 이동 + graph.add_edge("sql_generator", "sql_validator") + + # SQL 검증 후 조건부 라우팅 + graph.add_conditional_edges( + "sql_validator", + self.edges.should_execute_sql, + { + "regenerate": "sql_generator", + "execute": "sql_executor", + "synthesize_failure": "response_synthesizer" + } + ) + + # SQL 실행 후 조건부 라우팅 + graph.add_conditional_edges( + "sql_executor", + self.edges.should_retry_or_respond, + { + "regenerate": "sql_generator", + "synthesize_success": "response_synthesizer", + "synthesize_failure": "response_synthesizer" + } + ) + + # 응답 생성 후 종료 + graph.add_edge("response_synthesizer", END) + + async def run(self, initial_state: dict) -> dict: + """그래프를 실행하고 결과를 반환합니다.""" + try: + if self._graph is None: + self.create_graph() + + result = await self._graph.ainvoke(initial_state) + return result + + except Exception as e: + print(f"그래프 실행 중 오류 발생: {e}") + # 에러 발생 시 기본 응답 반환 + return { + **initial_state, + 'final_response': f"죄송합니다. 처리 중 오류가 발생했습니다: {e}" + } + + def get_graph_visualization(self) -> bytes: + """그래프의 시각화 이미지를 반환합니다.""" + if self._graph is None: + self.create_graph() + + try: + return self._graph.get_graph(xray=True).draw_mermaid_png() + except Exception as e: + print(f"그래프 시각화 생성 실패: {e}") + return None diff --git a/src/agents/sql_agent/nodes.py b/src/agents/sql_agent/nodes.py new file mode 100644 index 0000000..408f945 --- /dev/null +++ b/src/agents/sql_agent/nodes.py @@ -0,0 +1,298 @@ +# src/agents/sql_agent/nodes.py + +import os +import sys +import asyncio +from typing import List, Optional +from langchain.output_parsers.pydantic import PydanticOutputParser +from langchain_core.output_parsers import StrOutputParser +from langchain.prompts import load_prompt + +from schemas.agent.sql_schemas import SqlQuery +from services.database.database_service import DatabaseService +from core.providers.llm_provider import LLMProvider +from .state import SqlAgentState +from .exceptions import ( + ValidationException, + ExecutionException, + DatabaseConnectionException, + MaxRetryExceededException +) + +# 상수 정의 +MAX_ERROR_COUNT = 3 +PROMPT_VERSION = "v1" +PROMPT_DIR = os.path.join("prompts", PROMPT_VERSION, "sql_agent") + +def resource_path(relative_path): + """PyInstaller 경로 해결 함수""" + try: + base_path = sys._MEIPASS + except Exception: + base_path = os.path.abspath(os.path.join(os.path.dirname(__file__), "../..")) + return os.path.join(base_path, relative_path) + +class SqlAgentNodes: + """SQL Agent의 모든 노드 로직을 담당하는 클래스""" + + def __init__(self, llm_provider: LLMProvider, database_service: DatabaseService): + self.llm_provider = llm_provider + self.database_service = database_service + + # 프롬프트 로드 + self._load_prompts() + + def _load_prompts(self): + """프롬프트 파일들을 로드합니다.""" + try: + self.intent_classifier_prompt = load_prompt( + resource_path(os.path.join(PROMPT_DIR, "intent_classifier.yaml")) + ) + self.db_classifier_prompt = load_prompt( + resource_path(os.path.join(PROMPT_DIR, "db_classifier.yaml")) + ) + self.sql_generator_prompt = load_prompt( + resource_path(os.path.join(PROMPT_DIR, "sql_generator.yaml")) + ) + self.response_synthesizer_prompt = load_prompt( + resource_path(os.path.join(PROMPT_DIR, "response_synthesizer.yaml")) + ) + except Exception as e: + raise FileNotFoundError(f"프롬프트 파일 로드 실패: {e}") + + async def intent_classifier_node(self, state: SqlAgentState) -> SqlAgentState: + """사용자 질문의 의도를 분류하는 노드""" + print("--- 0. 의도 분류 중 ---") + + try: + llm = await self.llm_provider.get_llm() + chain = self.intent_classifier_prompt | llm | StrOutputParser() + intent = await chain.ainvoke({"question": state['question']}) + state['intent'] = intent.strip() + return state + + except Exception as e: + print(f"의도 분류 실패: {e}") + # 기본값으로 SQL 처리 + state['intent'] = "SQL" + return state + + async def unsupported_question_node(self, state: SqlAgentState) -> SqlAgentState: + """SQL과 관련 없는 질문을 처리하는 노드""" + print("--- SQL 관련 없는 질문 ---") + + state['final_response'] = """죄송합니다, 해당 질문에는 답변할 수 없습니다. +저는 데이터베이스 관련 질문만 처리할 수 있습니다. +SQL 쿼리나 데이터 분석과 관련된 질문을 해주세요.""" + + return state + + async def db_classifier_node(self, state: SqlAgentState) -> SqlAgentState: + """데이터베이스를 분류하고 스키마를 가져오는 노드""" + print("--- 0.5. DB 분류 중 ---") + + try: + # 데이터베이스 목록 가져오기 + available_dbs = await self.database_service.get_available_databases() + + if not available_dbs: + raise DatabaseConnectionException("사용 가능한 데이터베이스가 없습니다.") + + # 데이터베이스 옵션 생성 + db_options = "\n".join([ + f"- {db.database_name}: {db.description}" + for db in available_dbs + ]) + + # LLM을 사용하여 적절한 데이터베이스 선택 + llm = await self.llm_provider.get_llm() + chain = self.db_classifier_prompt | llm | StrOutputParser() + selected_db_name = await chain.ainvoke({ + "db_options": db_options, + "chat_history": state['chat_history'], + "question": state['question'] + }) + + selected_db_name = selected_db_name.strip() + state['selected_db'] = selected_db_name + + print(f'--- 선택된 DB: {selected_db_name} ---') + + # 선택된 데이터베이스의 스키마 정보 가져오기 + db_schema = await self.database_service.get_schema_for_db(selected_db_name) + state['db_schema'] = db_schema + + return state + + except Exception as e: + print(f"데이터베이스 분류 실패: {e}") + # 폴백: 기본 데이터베이스 사용 + state['selected_db'] = 'sakila' + state['db_schema'] = await self.database_service.get_fallback_schema('sakila') + return state + + async def sql_generator_node(self, state: SqlAgentState) -> SqlAgentState: + """SQL 쿼리를 생성하는 노드""" + print("--- 1. SQL 생성 중 ---") + + try: + parser = PydanticOutputParser(pydantic_object=SqlQuery) + + # 에러 피드백 컨텍스트 생성 + error_feedback = self._build_error_feedback(state) + + prompt = self.sql_generator_prompt.format( + format_instructions=parser.get_format_instructions(), + db_schema=state['db_schema'], + chat_history=state['chat_history'], + question=state['question'], + error_feedback=error_feedback + ) + + llm = await self.llm_provider.get_llm() + response = await llm.ainvoke(prompt) + parsed_query = parser.invoke(response.content) + + state['sql_query'] = parsed_query.query + state['validation_error'] = None + state['execution_result'] = None + + return state + + except Exception as e: + raise ExecutionException(f"SQL 생성 실패: {e}") + + def _build_error_feedback(self, state: SqlAgentState) -> str: + """에러 피드백 컨텍스트를 생성합니다.""" + error_feedback = "" + + # 검증 오류가 있었을 경우 + if state.get("validation_error") and state.get("validation_error_count", 0) > 0: + error_feedback = f""" + Your previous query was rejected for the following reason: {state['validation_error']} + Please generate a new, safe query that does not contain forbidden keywords. + """ + # 실행 오류가 있었을 경우 + elif (state.get("execution_result") and + "오류" in state.get("execution_result", "") and + state.get("execution_error_count", 0) > 0): + error_feedback = f""" + Your previously generated SQL query failed with the following database error: + FAILED SQL: {state['sql_query']} + DATABASE ERROR: {state['execution_result']} + Please correct the SQL query based on the error. + """ + + return error_feedback + + async def sql_validator_node(self, state: SqlAgentState) -> SqlAgentState: + """SQL 쿼리의 안전성을 검증하는 노드""" + print("--- 2. SQL 검증 중 ---") + + try: + query_words = state['sql_query'].lower().split() + dangerous_keywords = [ + "drop", "delete", "update", "insert", "truncate", + "alter", "create", "grant", "revoke" + ] + found_keywords = [keyword for keyword in dangerous_keywords if keyword in query_words] + + if found_keywords: + keyword_str = ', '.join(f"'{k}'" for k in found_keywords) + error_msg = f'위험한 키워드 {keyword_str}가 포함되어 있습니다.' + state['validation_error'] = error_msg + state['validation_error_count'] = state.get('validation_error_count', 0) + 1 + + if state['validation_error_count'] >= MAX_ERROR_COUNT: + raise MaxRetryExceededException( + f"SQL 검증 실패가 {MAX_ERROR_COUNT}회 반복됨", MAX_ERROR_COUNT + ) + else: + state['validation_error'] = None + state['validation_error_count'] = 0 + + return state + + except MaxRetryExceededException: + raise + except Exception as e: + raise ValidationException(f"SQL 검증 중 오류 발생: {e}") + + async def sql_executor_node(self, state: SqlAgentState) -> SqlAgentState: + """SQL 쿼리를 실행하는 노드""" + print("--- 3. SQL 실행 중 ---") + + try: + selected_db = state.get('selected_db', 'default') + result = await self.database_service.execute_query( + state['sql_query'], + database_name=selected_db + ) + + state['execution_result'] = result + state['validation_error_count'] = 0 + state['execution_error_count'] = 0 + + return state + + except Exception as e: + error_msg = f"실행 오류: {e}" + state['execution_result'] = error_msg + state['validation_error_count'] = 0 + state['execution_error_count'] = state.get('execution_error_count', 0) + 1 + + if state['execution_error_count'] >= MAX_ERROR_COUNT: + raise MaxRetryExceededException( + f"SQL 실행 실패가 {MAX_ERROR_COUNT}회 반복됨", MAX_ERROR_COUNT + ) + + return state + + async def response_synthesizer_node(self, state: SqlAgentState) -> SqlAgentState: + """최종 답변을 생성하는 노드""" + print("--- 4. 최종 답변 생성 중 ---") + + try: + is_failure = (state.get('validation_error_count', 0) >= MAX_ERROR_COUNT or + state.get('execution_error_count', 0) >= MAX_ERROR_COUNT) + + if is_failure: + context_message = self._build_failure_context(state) + else: + context_message = f""" + Successfully executed the SQL query to answer the user's question. + SQL Query: {state['sql_query']} + SQL Result: {state['execution_result']} + """ + + prompt = self.response_synthesizer_prompt.format( + question=state['question'], + chat_history=state['chat_history'], + context_message=context_message + ) + + llm = await self.llm_provider.get_llm() + response = await llm.ainvoke(prompt) + state['final_response'] = response.content + + return state + + except Exception as e: + # 최종 답변 생성 실패 시 기본 메시지 제공 + state['final_response'] = f"죄송합니다. 답변 생성 중 오류가 발생했습니다: {e}" + return state + + def _build_failure_context(self, state: SqlAgentState) -> str: + """실패 상황에 대한 컨텍스트 메시지를 생성합니다.""" + if state.get('validation_error_count', 0) >= MAX_ERROR_COUNT: + error_type = "SQL 검증" + error_details = state.get('validation_error') + else: + error_type = "SQL 실행" + error_details = state.get('execution_result') + + return f""" + An attempt to answer the user's question failed after multiple retries. + Failure Type: {error_type} + Last Error: {error_details} + """ diff --git a/src/agents/sql_agent/state.py b/src/agents/sql_agent/state.py new file mode 100644 index 0000000..ab6aa1c --- /dev/null +++ b/src/agents/sql_agent/state.py @@ -0,0 +1,30 @@ +# src/agents/sql_agent/state.py + +from typing import List, TypedDict, Optional +from langchain_core.messages import BaseMessage + +class SqlAgentState(TypedDict): + """SQL Agent의 상태를 정의하는 TypedDict""" + + # 입력 정보 + question: str + chat_history: List[BaseMessage] + + # 데이터베이스 관련 + selected_db: Optional[str] + db_schema: str + + # 의도 분류 결과 + intent: str + + # SQL 생성 및 검증 + sql_query: str + validation_error: Optional[str] + validation_error_count: int + + # SQL 실행 결과 + execution_result: Optional[str] + execution_error_count: int + + # 최종 응답 + final_response: str diff --git a/src/agents/sql_agent_graph.py b/src/agents/sql_agent_graph.py deleted file mode 100644 index 71d59b0..0000000 --- a/src/agents/sql_agent_graph.py +++ /dev/null @@ -1,285 +0,0 @@ -# src/agents/sql_agent_graph.py - -import os -import sys -from typing import List, TypedDict, Optional -from langchain_core.messages import BaseMessage -from langgraph.graph import StateGraph, END -from langchain.output_parsers.pydantic import PydanticOutputParser -from langchain_core.output_parsers import StrOutputParser -from langchain.prompts import load_prompt -from schemas.sql_schemas import SqlQuery -from core.db_manager import db_instance -from core.llm_provider import llm_instance - -# --- PyInstaller 경로 해결 함수 --- -def resource_path(relative_path): - try: - # PyInstaller creates a temp folder and stores path in _MEIPASS - base_path = sys._MEIPASS - except Exception: - # 개발 환경에서는 src 폴더를 기준으로 경로 설정 - base_path = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) - return os.path.join(base_path, relative_path) - -# --- 상수 정의 --- -MAX_ERROR_COUNT = 3 -PROMPT_VERSION = "v1" -PROMPT_DIR = os.path.join("prompts", PROMPT_VERSION, "sql_agent") - -# --- 프롬프트 로드 --- -INTENT_CLASSIFIER_PROMPT = load_prompt(resource_path(os.path.join(PROMPT_DIR, "intent_classifier.yaml"))) -DB_CLASSIFIER_PROMPT = load_prompt(resource_path(os.path.join(PROMPT_DIR, "db_classifier.yaml"))) -SQL_GENERATOR_PROMPT = load_prompt(resource_path(os.path.join(PROMPT_DIR, "sql_generator.yaml"))) -RESPONSE_SYNTHESIZER_PROMPT = load_prompt(resource_path(os.path.join(PROMPT_DIR, "response_synthesizer.yaml"))) - -# Agent 상태 정의 -class SqlAgentState(TypedDict): - question: str - chat_history: List[BaseMessage] - db_schema: str - intent: str - sql_query: str - validation_error: Optional[str] - validation_error_count: int - execution_result: Optional[str] - execution_error_count: int - final_response: str - -# --- 노드 함수 정의 --- -def intent_classifier_node(state: SqlAgentState): - print("--- 0. 의도 분류 중 ---") - chain = INTENT_CLASSIFIER_PROMPT | llm_instance | StrOutputParser() - intent = chain.invoke({"question": state['question']}) - state['intent'] = intent - return state - -def unsupported_question_node(state: SqlAgentState): - print("--- SQL 관련 없는 질문 ---") - state['final_response'] = "죄송합니다, 해당 질문에는 답변할 수 없습니다. 데이터베이스 관련 질문만 가능합니다." - return state - -def db_classifier_node(state: SqlAgentState): - print("--- 0.5. DB 분류 중 ---") - - # TODO: BE API 호출로 대체 필요 - available_dbs = [ - { - "connection_name": "local_mysql", - "database_name": "sakila", - "description": "DVD 대여점 비즈니스 모델을 다루는 샘플 데이터베이스로, 영화, 배우, 고객, 대여 기록 등의 정보를 포함합니다." - }, - { - "connection_name": "local_mysql", - "database_name": "ecom_prod", - "description": "온라인 쇼핑몰의 운영 데이터베이스로, 상품 카탈로그, 고객 주문, 재고 및 배송 정보를 관리합니다." - }, - { - "connection_name": "local_mysql", - "database_name": "hr_analytics", - "description": "회사의 인사 관리 데이터베이스로, 직원 정보, 급여, 부서, 성과 평가 기록을 포함합니다." - }, - { - "connection_name": "local_mysql", - "database_name": "web_logs", - "description": "웹사이트 트래픽 분석을 위한 로그 데이터베이스로, 사용자 방문 기록, 페이지 뷰, 에러 로그 등을 저장합니다." - } - ] - - db_options = "\n".join([f"- {db['database_name']}: {db['description']}" for db in available_dbs]) - - chain = DB_CLASSIFIER_PROMPT | llm_instance | StrOutputParser() - selected_db_name = chain.invoke({ - "db_options": db_options, - "chat_history": state['chat_history'], - "question": state['question'] - }) - - state['selected_db'] = selected_db_name.strip() - - # 선택된 DB의 스키마 정보를 가져와서 상태에 업데이트합니다. - print(f'--- 선택된 DB: {selected_db_name} ---') - - # TODO: get_schema_for_db - state['db_schema'] = db_instance.get_schema_for_db(db_name=selected_db_name) - - return state - -def sql_generator_node(state: SqlAgentState): - print("--- 1. SQL 생성 중 ---") - parser = PydanticOutputParser(pydantic_object=SqlQuery) - - # --- 에러 피드백 컨텍스트 생성 --- - error_feedback = "" - # 1. 검증 오류가 있었을 경우 - if state.get("validation_error") and state.get("validation_error_count", 0) > 0: - error_feedback = f""" - Your previous query was rejected for the following reason: {state['validation_error']} - Please generate a new, safe query that does not contain forbidden keywords. - """ - # 2. 실행 오류가 있었을 경우 - elif state.get("execution_result") and "오류" in state.get("execution_result") and state.get("execution_error_count", 0) > 0: - error_feedback = f""" - Your previously generated SQL query failed with the following database error: - FAILED SQL: {state['sql_query']} - DATABASE ERROR: {state['execution_result']} - Please correct the SQL query based on the error. - """ - - prompt = SQL_GENERATOR_PROMPT.format( - format_instructions=parser.get_format_instructions(), - db_schema=state['db_schema'], - chat_history=state['chat_history'], - question=state['question'], - error_feedback=error_feedback - ) - - response = llm_instance.invoke(prompt) - parsed_query = parser.invoke(response.content) - state['sql_query'] = parsed_query.query - state['validation_error'] = None - state['execution_result'] = None - return state - -def sql_validator_node(state: SqlAgentState): - print("--- 2. SQL 검증 중 ---") - query_words = state['sql_query'].lower().split() - dangerous_keywords = [ - "drop", "delete", "update", "insert", "truncate", - "alter", "create", "grant", "revoke" - ] - found_keywords = [keyword for keyword in dangerous_keywords if keyword in query_words] - - if found_keywords: - keyword_str = ', '.join(f"'{k}'" for k in found_keywords) - state['validation_error'] = f'위험한 키워드 {keyword_str}가 포함되어 있습니다.' - state['validation_error_count'] += 1 # sql 검증 횟수 추가 - else: - state['validation_error'] = None - state['validation_error_count'] = 0 # sql 검증 횟수 초기화 - return state - -def sql_executor_node(state: SqlAgentState): - print("--- 3. SQL 실행 중 ---") - try: - result = db_instance.run(state['sql_query']) - state['execution_result'] = str(result) - state['validation_error_count'] = 0 # sql 검증 횟수 초기화 - state['execution_error_count'] = 0 # sql 실행 횟수 초기화 - except Exception as e: - state['execution_result'] = f"실행 오류: {e}" - state['validation_error_count'] = 0 # sql 검증 횟수 초기화 - state['execution_error_count'] += 1 # sql 실행 횟수 추가 - return state - -def response_synthesizer_node(state: SqlAgentState): - print("--- 4. 최종 답변 생성 중 ---") - - is_failure = state.get('validation_error_count', 0) >= MAX_ERROR_COUNT or \ - state.get('execution_error_count', 0) >= MAX_ERROR_COUNT - - if is_failure: - if state.get('validation_error_count', 0) >= MAX_ERROR_COUNT: - error_type = "SQL 검증" - error_details = state.get('validation_error') - else: - error_type = "SQL 실행" - error_details = state.get('execution_result') - - context_message = f""" - An attempt to answer the user's question failed after multiple retries. - Failure Type: {error_type} - Last Error: {error_details} - """ - else: - context_message = f""" - Successfully executed the SQL query to answer the user's question. - SQL Query: {state['sql_query']} - SQL Result: {state['execution_result']} - """ - - prompt = RESPONSE_SYNTHESIZER_PROMPT.format( - question=state['question'], - chat_history=state['chat_history'], - context_message=context_message - ) - response = llm_instance.invoke(prompt) - state['final_response'] = response.content - return state - -# --- 엣지 함수 정의 --- -def route_after_intent_classification(state: SqlAgentState): - if state['intent'] == "SQL": - print("--- 의도: SQL 관련 질문 ---") - return "db_classifier" - print("--- 의도: SQL과 관련 없는 질문 ---") - return "unsupported_question" - -def should_execute_sql(state: SqlAgentState): - if state.get("validation_error_count", 0) >= MAX_ERROR_COUNT: - print(f"--- 검증 실패 {MAX_ERROR_COUNT}회 초과: 답변 생성으로 이동 ---") - return "synthesize_failure" - if state.get("validation_error"): - print(f"--- 검증 실패 {state['validation_error_count']}회: SQL 재생성 ---") - return "regenerate" - print("--- 검증 성공: SQL 실행 ---") - return "execute" - -def should_retry_or_respond(state: SqlAgentState): - if state.get("execution_error_count", 0) >= MAX_ERROR_COUNT: - print(f"--- 실행 실패 {MAX_ERROR_COUNT}회 초과: 답변 생성으로 이동 ---") - return "synthesize_failure" - if "오류" in (state.get("execution_result") or ""): - print(f"--- 실행 실패 {state['execution_error_count']}회: SQL 재생성 ---") - return "regenerate" - print("--- 실행 성공: 최종 답변 생성 ---") - return "synthesize_success" - -# --- 그래프 구성 --- -def create_sql_agent_graph() -> StateGraph: - graph = StateGraph(SqlAgentState) - - graph.add_node("intent_classifier", intent_classifier_node) - graph.add_node("db_classifier", db_classifier_node) - graph.add_node("unsupported_question", unsupported_question_node) - graph.add_node("sql_generator", sql_generator_node) - graph.add_node("sql_validator", sql_validator_node) - graph.add_node("sql_executor", sql_executor_node) - graph.add_node("response_synthesizer", response_synthesizer_node) - - graph.set_entry_point("intent_classifier") - - graph.add_conditional_edges( - "intent_classifier", - route_after_intent_classification, - { - "db_classifier": "db_classifier", - "unsupported_question": "unsupported_question" - } - ) - graph.add_edge("unsupported_question", END) - - graph.add_edge("db_classifier", "sql_generator") - - graph.add_edge("sql_generator", "sql_validator") - - graph.add_conditional_edges("sql_validator", should_execute_sql, { - "regenerate": "sql_generator", - "execute": "sql_executor", - "synthesize_failure": "response_synthesizer" - }) - graph.add_conditional_edges("sql_executor", should_retry_or_respond, { - "regenerate": "sql_generator", - "synthesize_success": "response_synthesizer", - "synthesize_failure": "response_synthesizer" - }) - graph.add_edge("response_synthesizer", END) - - return graph.compile() - -sql_agent_app = create_sql_agent_graph() - -# 워크 플로우 그림 작성 -# graph_image_bytes = sql_agent_app.get_graph(xray=True).draw_mermaid_png() -# with open("workflow_graph.png", "wb") as f: -# f.write(graph_image_bytes) \ No newline at end of file From 52e90fbdc44f0e5ca922ba18e300dad04512639c Mon Sep 17 00:00:00 2001 From: CheckerBoard Date: Sun, 17 Aug 2025 18:00:08 +0900 Subject: [PATCH 03/30] =?UTF-8?q?refactor:=20=EC=97=94=EB=93=9C=20?= =?UTF-8?q?=ED=8F=AC=EC=9D=B8=ED=8A=B8=20=EB=B0=8F=20=EB=9D=BC=EC=9A=B0?= =?UTF-8?q?=ED=84=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/v1/__init__.py | 6 ++ src/api/v1/endpoints/annotator.py | 24 ------- src/api/v1/endpoints/chat.py | 28 -------- src/api/v1/routers/annotator.py | 67 ++++++++++++++++++ src/api/v1/routers/chat.py | 91 +++++++++++++++++++++++++ src/api/v1/routers/health.py | 77 +++++++++++++++++++++ src/api/v1/schemas/annotator_schemas.py | 47 ------------- src/api/v1/schemas/chatbot_schemas.py | 16 ----- 8 files changed, 241 insertions(+), 115 deletions(-) create mode 100644 src/api/v1/__init__.py delete mode 100644 src/api/v1/endpoints/annotator.py delete mode 100644 src/api/v1/endpoints/chat.py create mode 100644 src/api/v1/routers/annotator.py create mode 100644 src/api/v1/routers/chat.py create mode 100644 src/api/v1/routers/health.py delete mode 100644 src/api/v1/schemas/annotator_schemas.py delete mode 100644 src/api/v1/schemas/chatbot_schemas.py diff --git a/src/api/v1/__init__.py b/src/api/v1/__init__.py new file mode 100644 index 0000000..cd0ff49 --- /dev/null +++ b/src/api/v1/__init__.py @@ -0,0 +1,6 @@ +""" +API v1 패키지 +""" + + + diff --git a/src/api/v1/endpoints/annotator.py b/src/api/v1/endpoints/annotator.py deleted file mode 100644 index 508fb94..0000000 --- a/src/api/v1/endpoints/annotator.py +++ /dev/null @@ -1,24 +0,0 @@ - -from fastapi import APIRouter, Depends -from core.llm_provider import llm_instance -from services.annotation_service import AnnotationService -from api.v1.schemas.annotator_schemas import AnnotationRequest, AnnotationResponse - -router = APIRouter() - -# AnnotationService 인스턴스를 싱글턴으로 관리 -annotation_service_instance = AnnotationService(llm=llm_instance) - -def get_annotation_service(): - """의존성 주입을 통해 AnnotationService 인스턴스를 제공합니다.""" - return annotation_service_instance - -@router.post("/annotator", response_model=AnnotationResponse) -async def create_annotations( - request: AnnotationRequest, - service: AnnotationService = Depends(get_annotation_service) -): - """ - DB 스키마 정보를 받아 각 요소에 대한 설명을 비동기적으로 생성하여 반환합니다. - """ - return await service.generate_for_schema(request) diff --git a/src/api/v1/endpoints/chat.py b/src/api/v1/endpoints/chat.py deleted file mode 100644 index 69ee393..0000000 --- a/src/api/v1/endpoints/chat.py +++ /dev/null @@ -1,28 +0,0 @@ -# src/api/v1/endpoints/chat.py - -from fastapi import APIRouter, Depends -from api.v1.schemas.chatbot_schemas import ChatRequest, ChatResponse -from services.chatbot_service import ChatbotService - -router = APIRouter() - -def get_chatbot_service(): - return ChatbotService() - -@router.post("/chat", response_model=ChatResponse) -def handle_chat_request( - request: ChatRequest, - service: ChatbotService = Depends(get_chatbot_service) -): - """ - 사용자의 채팅 요청을 받아 챗봇의 답변을 반환합니다. - Args: - request: 챗봇 요청 - service: 챗봇 서비스 로직 - - Returns: - ChatRespone: 챗봇 응답 - """ - final_answer = service.handle_request(request.question, request.chat_history) - - return ChatResponse(answer=final_answer) \ No newline at end of file diff --git a/src/api/v1/routers/annotator.py b/src/api/v1/routers/annotator.py new file mode 100644 index 0000000..e918ce8 --- /dev/null +++ b/src/api/v1/routers/annotator.py @@ -0,0 +1,67 @@ +# src/api/v1/routers/annotator.py + +from fastapi import APIRouter, HTTPException, Depends +from typing import Dict, Any + +from schemas.api.annotator_schemas import AnnotationRequest, AnnotationResponse +from services.annotation.annotation_service import AnnotationService, get_annotation_service +import logging + +logger = logging.getLogger(__name__) + +router = APIRouter() + +@router.post("/annotator", response_model=AnnotationResponse) +async def create_annotations( + request: AnnotationRequest, + service: AnnotationService = Depends(get_annotation_service) +) -> AnnotationResponse: + """ + DB 스키마 정보를 받아 각 요소에 대한 설명을 비동기적으로 생성하여 반환합니다. + + Args: + request: 어노테이션 요청 (DB 스키마 정보) + service: 어노테이션 서비스 로직 + + Returns: + AnnotationResponse: 어노테이션이 추가된 스키마 정보 + + Raises: + HTTPException: 요청 처리 실패 시 + """ + try: + logger.info(f"Received annotation request for {len(request.databases)} databases") + + response = await service.generate_for_schema(request) + + logger.info("Annotation request processed successfully") + + return response + + except Exception as e: + logger.error(f"Annotation request failed: {e}") + raise HTTPException( + status_code=500, + detail=f"어노테이션 생성 중 오류가 발생했습니다: {e}" + ) + +@router.get("/annotator/health") +async def annotator_health_check( + service: AnnotationService = Depends(get_annotation_service) +) -> Dict[str, Any]: + """ + 어노테이션 서비스의 상태를 확인합니다. + + Returns: + Dict: 서비스 상태 정보 + """ + try: + health_status = await service.health_check() + return health_status + + except Exception as e: + logger.error(f"Annotator health check failed: {e}") + return { + "status": "unhealthy", + "error": str(e) + } diff --git a/src/api/v1/routers/chat.py b/src/api/v1/routers/chat.py new file mode 100644 index 0000000..113196b --- /dev/null +++ b/src/api/v1/routers/chat.py @@ -0,0 +1,91 @@ +# src/api/v1/routers/chat.py + +from fastapi import APIRouter, HTTPException, Depends +from typing import Dict, Any, List + +from schemas.api.chat_schemas import ChatRequest, ChatResponse +from services.chat.chatbot_service import ChatbotService, get_chatbot_service +import logging + +logger = logging.getLogger(__name__) + +router = APIRouter() + +@router.post("/chat", response_model=ChatResponse) +async def handle_chat_request( + request: ChatRequest, + service: ChatbotService = Depends(get_chatbot_service) +) -> ChatResponse: + """ + 사용자의 채팅 요청을 받아 챗봇의 답변을 반환합니다. + + Args: + request: 챗봇 요청 (질문과 채팅 히스토리) + service: 챗봇 서비스 로직 + + Returns: + ChatResponse: 챗봇 응답 + + Raises: + HTTPException: 요청 처리 실패 시 + """ + try: + logger.info(f"Received chat request: {request.question[:100]}...") + + final_answer = await service.handle_request( + user_question=request.question, + chat_history=request.chat_history + ) + + logger.info("Chat request processed successfully") + + return ChatResponse(answer=final_answer) + + except Exception as e: + logger.error(f"Chat request failed: {e}") + raise HTTPException( + status_code=500, + detail=f"채팅 요청 처리 중 오류가 발생했습니다: {e}" + ) + +@router.get("/chat/health") +async def chat_health_check( + service: ChatbotService = Depends(get_chatbot_service) +) -> Dict[str, Any]: + """ + 챗봇 서비스의 상태를 확인합니다. + + Returns: + Dict: 서비스 상태 정보 + """ + try: + health_status = await service.health_check() + return health_status + + except Exception as e: + logger.error(f"Chat health check failed: {e}") + return { + "status": "unhealthy", + "error": str(e) + } + +@router.get("/chat/databases") +async def get_available_databases( + service: ChatbotService = Depends(get_chatbot_service) +) -> Dict[str, List[Dict[str, str]]]: + """ + 사용 가능한 데이터베이스 목록을 반환합니다. + + Returns: + Dict: 데이터베이스 목록 + """ + try: + databases = await service.get_available_databases() + return {"databases": databases} + + except Exception as e: + logger.error(f"Failed to get databases: {e}") + raise HTTPException( + status_code=500, + detail=f"데이터베이스 목록 조회 중 오류가 발생했습니다: {e}" + ) diff --git a/src/api/v1/routers/health.py b/src/api/v1/routers/health.py new file mode 100644 index 0000000..1ea2ce0 --- /dev/null +++ b/src/api/v1/routers/health.py @@ -0,0 +1,77 @@ +# src/api/v1/routers/health.py + +from fastapi import APIRouter, Depends +from typing import Dict, Any + +from services.chat.chatbot_service import ChatbotService, get_chatbot_service +from services.annotation.annotation_service import AnnotationService, get_annotation_service +from services.database.database_service import DatabaseService, get_database_service +import logging + +logger = logging.getLogger(__name__) + +router = APIRouter() + +@router.get("/health") +async def root_health_check() -> Dict[str, str]: + """ + 루트 헬스체크 엔드포인트, 서버 상태가 정상이면 'ok' 반환합니다. + + Returns: + Dict: 기본 상태 정보 + """ + return { + "status": "ok", + "message": "Welcome to the QGenie Chatbot AI!", + "version": "2.0.0" + } + +@router.get("/health/detailed") +async def detailed_health_check( + chatbot_service: ChatbotService = Depends(get_chatbot_service), + annotation_service: AnnotationService = Depends(get_annotation_service), + database_service: DatabaseService = Depends(get_database_service) +) -> Dict[str, Any]: + """ + 전체 시스템의 상세 헬스체크를 수행합니다. + + Returns: + Dict: 상세 상태 정보 + """ + try: + # 모든 서비스의 헬스체크를 병렬로 실행 + import asyncio + + chatbot_health, annotation_health, database_health = await asyncio.gather( + chatbot_service.health_check(), + annotation_service.health_check(), + database_service.health_check(), + return_exceptions=True + ) + + # 각 서비스 상태 처리 + services_status = { + "chatbot": chatbot_health if not isinstance(chatbot_health, Exception) else {"status": "unhealthy", "error": str(chatbot_health)}, + "annotation": annotation_health if not isinstance(annotation_health, Exception) else {"status": "unhealthy", "error": str(annotation_health)}, + "database": {"status": "healthy" if database_health and not isinstance(database_health, Exception) else "unhealthy"} + } + + # 전체 상태 결정 + all_healthy = all( + service.get("status") == "healthy" + for service in services_status.values() + ) + + return { + "status": "healthy" if all_healthy else "partial", + "services": services_status, + "timestamp": __import__("datetime").datetime.now().isoformat() + } + + except Exception as e: + logger.error(f"Detailed health check failed: {e}") + return { + "status": "unhealthy", + "error": str(e), + "timestamp": __import__("datetime").datetime.now().isoformat() + } diff --git a/src/api/v1/schemas/annotator_schemas.py b/src/api/v1/schemas/annotator_schemas.py deleted file mode 100644 index 7db3174..0000000 --- a/src/api/v1/schemas/annotator_schemas.py +++ /dev/null @@ -1,47 +0,0 @@ -# src/api/v1/schemas/annotator_schemas.py - -from pydantic import BaseModel, Field -from typing import List, Dict, Any - -class Column(BaseModel): - column_name: str - data_type: str - -class Table(BaseModel): - table_name: str - columns: List[Column] - sample_rows: List[Dict[str, Any]] - -class Relationship(BaseModel): - from_table: str - from_columns: List[str] - to_table: str - to_columns: List[str] - -class Database(BaseModel): - database_name: str - tables: List[Table] - relationships: List[Relationship] - -class AnnotationRequest(BaseModel): - dbms_type: str - databases: List[Database] - -class AnnotatedColumn(Column): - description: str = Field(..., description="AI가 생성한 컬럼 설명") - -class AnnotatedTable(Table): - description: str = Field(..., description="AI가 생성한 테이블 설명") - columns: List[AnnotatedColumn] - -class AnnotatedRelationship(Relationship): - description: str = Field(..., description="AI가 생성한 관계 설명") - -class AnnotatedDatabase(Database): - description: str = Field(..., description="AI가 생성한 데이터베이스 설명") - tables: List[AnnotatedTable] - relationships: List[AnnotatedRelationship] - -class AnnotationResponse(BaseModel): - dbms_type: str - databases: List[AnnotatedDatabase] diff --git a/src/api/v1/schemas/chatbot_schemas.py b/src/api/v1/schemas/chatbot_schemas.py deleted file mode 100644 index f3ae457..0000000 --- a/src/api/v1/schemas/chatbot_schemas.py +++ /dev/null @@ -1,16 +0,0 @@ -# src/api/v1/schemas/chatbot_schemas.py - -from pydantic import BaseModel -from typing import List, Optional - -class ChatMessage(BaseModel): - """대화 기록의 단일 메시지를 나타내는 모델""" - role: str # "user" 또는 "assistant" - content: str - -class ChatRequest(BaseModel): - question: str - chat_history: Optional[List[ChatMessage]] = None - -class ChatResponse(BaseModel): - answer: str From ba4a95392700687b49edef30e1d8241a5b882fd0 Mon Sep 17 00:00:00 2001 From: CheckerBoard Date: Sun, 17 Aug 2025 18:00:51 +0900 Subject: [PATCH 04/30] =?UTF-8?q?refactor:=20init=20=ED=8C=8C=EC=9D=BC=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/__init__.py | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 src/api/__init__.py diff --git a/src/api/__init__.py b/src/api/__init__.py new file mode 100644 index 0000000..365cd08 --- /dev/null +++ b/src/api/__init__.py @@ -0,0 +1,6 @@ +""" +API 패키지 루트 +""" + + + From 73082aee7376928616d765bc465769f33f9475e8 Mon Sep 17 00:00:00 2001 From: CheckerBoard Date: Sun, 17 Aug 2025 18:01:11 +0900 Subject: [PATCH 05/30] =?UTF-8?q?feat:=20api=20=EC=9A=94=EC=B2=AD=EC=9D=84?= =?UTF-8?q?=20=EC=9C=84=ED=95=9C=20=ED=81=B4=EB=9D=BC=EC=9D=B4=EC=96=B8?= =?UTF-8?q?=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/core/clients/api_client.py | 234 +++++++++++++++++++++++++++++++++ 1 file changed, 234 insertions(+) create mode 100644 src/core/clients/api_client.py diff --git a/src/core/clients/api_client.py b/src/core/clients/api_client.py new file mode 100644 index 0000000..e5c1dce --- /dev/null +++ b/src/core/clients/api_client.py @@ -0,0 +1,234 @@ +# src/core/clients/api_client.py + +import httpx +import asyncio +from typing import List, Dict, Any, Optional +from pydantic import BaseModel +import logging + +# 로깅 설정 +logger = logging.getLogger(__name__) + +class DatabaseInfo(BaseModel): + """데이터베이스 정보 모델""" + connection_name: str + database_name: str + description: str + +class QueryExecutionRequest(BaseModel): + """쿼리 실행 요청 모델""" + sql_query: str + database_name: str + execution_timeout: int = 30 + user_id: Optional[str] = None + +class QueryExecutionResponse(BaseModel): + """쿼리 실행 응답 모델""" + success: bool + result: Optional[str] = None + error: Optional[str] = None + execution_time: Optional[float] = None + row_count: Optional[int] = None + +class APIClient: + """백엔드 API와 통신하는 클라이언트 클래스""" + + def __init__(self, base_url: str = "http://localhost:39722"): + self.base_url = base_url + self.timeout = httpx.Timeout(30.0) + self.headers = { + "Content-Type": "application/json" + } + self._client: Optional[httpx.AsyncClient] = None + + async def _get_client(self) -> httpx.AsyncClient: + """재사용 가능한 HTTP 클라이언트를 반환합니다.""" + if self._client is None or self._client.is_closed: + self._client = httpx.AsyncClient(timeout=self.timeout) + return self._client + + async def close(self): + """HTTP 클라이언트 연결을 닫습니다.""" + if self._client and not self._client.is_closed: + await self._client.aclose() + + async def get_available_databases(self) -> List[DatabaseInfo]: + """사용 가능한 데이터베이스 목록을 가져옵니다.""" + try: + client = await self._get_client() + response = await client.get( + f"{self.base_url}/api/v1/databases", + headers=self.headers + ) + response.raise_for_status() + + data = response.json() + databases = [DatabaseInfo(**db) for db in data.get("databases", [])] + logger.info(f"Successfully fetched {len(databases)} databases") + return databases + + except httpx.HTTPStatusError as e: + logger.error(f"HTTP error occurred: {e.response.status_code} - {e.response.text}") + raise + except httpx.RequestError as e: + logger.error(f"Request error occurred: {e}") + raise + except Exception as e: + logger.error(f"Unexpected error: {e}") + raise + + async def get_database_schema(self, database_name: str) -> str: + """특정 데이터베이스의 스키마 정보를 가져옵니다.""" + try: + client = await self._get_client() + response = await client.get( + f"{self.base_url}/api/v1/databases/{database_name}/schema", + headers=self.headers + ) + response.raise_for_status() + + data = response.json() + schema = data.get("schema", "") + logger.info(f"Successfully fetched schema for database: {database_name}") + return schema + + except httpx.HTTPStatusError as e: + logger.error(f"HTTP error occurred: {e.response.status_code} - {e.response.text}") + raise + except httpx.RequestError as e: + logger.error(f"Request error occurred: {e}") + raise + except Exception as e: + logger.error(f"Unexpected error: {e}") + raise + + async def execute_query( + self, + sql_query: str, + database_name: str, + timeout: int = 30 + ) -> QueryExecutionResponse: + """SQL 쿼리를 Backend 서버에 전송하여 실행하고 결과를 받아옵니다.""" + try: + logger.info(f"Sending SQL query to backend: {sql_query}") + + request_data = QueryExecutionRequest( + sql_query=sql_query, + database_name=database_name, + execution_timeout=timeout + ) + + client = await self._get_client() + response = await client.post( + f"{self.base_url}/api/query/execute/actions", + json=request_data.model_dump(), + headers=self.headers, + timeout=httpx.Timeout(timeout + 5) + ) + + if response.status_code == 200: + data = response.json() + result = QueryExecutionResponse(**data) + logger.info(f"Query executed successfully in {result.execution_time}s") + return result + else: + error_data = response.json() + error_msg = error_data.get("error", f"HTTP {response.status_code} error") + logger.error(f"Backend API error: {error_msg}") + + return QueryExecutionResponse( + success=False, + error=error_msg + ) + + except httpx.TimeoutException: + logger.error("Backend API 요청 시간 초과") + return QueryExecutionResponse( + success=False, + error="쿼리 실행 시간 초과: Backend 서버 응답이 늦습니다." + ) + except httpx.ConnectError: + logger.error("Backend 서버 연결 실패") + return QueryExecutionResponse( + success=False, + error="Backend 서버에 연결할 수 없습니다. 서버 상태를 확인해주세요." + ) + except httpx.HTTPStatusError as e: + logger.error(f"HTTP error occurred: {e.response.status_code} - {e.response.text}") + return QueryExecutionResponse( + success=False, + error=f"HTTP 오류: {e.response.status_code}" + ) + except Exception as e: + logger.error(f"Unexpected error during query execution: {e}") + return QueryExecutionResponse( + success=False, + error=f"쿼리 실행 중 예상치 못한 오류: {e}" + ) + + async def health_check(self) -> bool: + """API 서버 상태를 확인합니다.""" + try: + client = await self._get_client() + response = await client.get( + f"{self.base_url}/health", + timeout=httpx.Timeout(5.0) + ) + return response.status_code == 200 + except Exception as e: + logger.error(f"Health check failed: {e}") + return False + + async def get_openai_api_key(self) -> str: + """백엔드에서 OpenAI API 키를 가져옵니다.""" + try: + client = await self._get_client() + response = await client.get( + f"{self.base_url}/api/keys/find", + headers=self.headers, + timeout=httpx.Timeout(10.0) + ) + response.raise_for_status() + + data = response.json() + + # data 배열에서 OpenAI 서비스 찾기 + api_keys = data.get("data", []) + openai_key = None + + # 가장 첫번째 OpenAI 키 사용 + for key_info in api_keys: + if key_info.get("service_name") == "OpenAI": + openai_key = key_info.get("id") + break + + if not openai_key: + raise ValueError("백엔드에서 OpenAI API 키를 찾을 수 없습니다.") + + logger.info("Successfully fetched OpenAI API key from backend") + return openai_key + + except httpx.HTTPStatusError as e: + logger.error(f"HTTP error occurred while fetching API key: {e.response.status_code} - {e.response.text}") + raise + except httpx.RequestError as e: + logger.error(f"Request error occurred while fetching API key: {e}") + raise + except Exception as e: + logger.error(f"Unexpected error while fetching API key: {e}") + raise + + async def __aenter__(self): + """비동기 컨텍스트 매니저 진입""" + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb): + """비동기 컨텍스트 매니저 종료""" + await self.close() + +# 싱글톤 인스턴스 +_api_client = APIClient() + +async def get_api_client() -> APIClient: + """API Client 인스턴스를 반환합니다.""" + return _api_client From 8b2c622f59dc8e386b663b55503fdf92ebab8e90 Mon Sep 17 00:00:00 2001 From: CheckerBoard Date: Sun, 17 Aug 2025 18:01:41 +0900 Subject: [PATCH 06/30] =?UTF-8?q?refactor:=20llm=20=EC=97=B0=EA=B2=B0=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/core/__init__.py | 15 +++++ src/core/llm_provider.py | 30 ---------- src/core/providers/llm_provider.py | 95 ++++++++++++++++++++++++++++++ 3 files changed, 110 insertions(+), 30 deletions(-) delete mode 100644 src/core/llm_provider.py create mode 100644 src/core/providers/llm_provider.py diff --git a/src/core/__init__.py b/src/core/__init__.py index e69de29..9c22b0b 100644 --- a/src/core/__init__.py +++ b/src/core/__init__.py @@ -0,0 +1,15 @@ +# src/core/__init__.py + +""" +코어 모듈 - 기본 인프라스트럭처 구성 요소들 +""" + +from .providers.llm_provider import LLMProvider, get_llm_provider +from .clients.api_client import APIClient, get_api_client + +__all__ = [ + 'LLMProvider', + 'get_llm_provider', + 'APIClient', + 'get_api_client' +] diff --git a/src/core/llm_provider.py b/src/core/llm_provider.py deleted file mode 100644 index 3a5a94c..0000000 --- a/src/core/llm_provider.py +++ /dev/null @@ -1,30 +0,0 @@ -# src/core/llm_provider.py - -import os -from langchain_openai import ChatOpenAI -from dotenv import load_dotenv - -load_dotenv() - -def get_llm() -> ChatOpenAI: - """ 사전 설정된 ChatOpenAI 인스턴스를 생성하고 반환합니다. - Prams: - - Returns: - llm: 생성된 ChatOpenAI 객체 - """ - # 환경 변수에서 OpenAI API 키를 가져옵니다. - api_key = os.getenv("OPENAI_API_KEY") - if not api_key: - raise ValueError("OPENAI_API_KEY 환경 변수가 설정되지 않았습니다.") - - # 기본값으로 gpt-4o-mini 모델 사용 - llm = ChatOpenAI( - model="gpt-4o-mini", - temperature=0, - api_key=api_key - ) - - return llm - -llm_instance = get_llm() \ No newline at end of file diff --git a/src/core/providers/llm_provider.py b/src/core/providers/llm_provider.py new file mode 100644 index 0000000..08abb3d --- /dev/null +++ b/src/core/providers/llm_provider.py @@ -0,0 +1,95 @@ +# src/core/providers/llm_provider.py + +import os +import asyncio +import logging +from typing import Optional +from langchain_openai import ChatOpenAI +from dotenv import load_dotenv +from core.clients.api_client import get_api_client + +load_dotenv() + +logger = logging.getLogger(__name__) + +class LLMProvider: + """LLM 제공자를 관리하는 클래스""" + + def __init__(self, model_name: str = "gpt-4o-mini", temperature: float = 0): + self.model_name = model_name + self.temperature = temperature + self._llm: Optional[ChatOpenAI] = None + self._api_key: Optional[str] = None + self._api_client = None + + async def _load_api_key(self) -> str: + """백엔드에서 OpenAI API 키를 로드합니다.""" + try: + if self._api_key is None: + if self._api_client is None: + self._api_client = await get_api_client() + + self._api_key = await self._api_client.get_openai_api_key() + + return self._api_key + + except Exception as e: + # 백엔드 실패 시 환경 변수로 폴백 + logger.warning(f"Failed to fetch API key from backend: {e}, falling back to environment variable") + api_key = os.getenv("OPENAI_API_KEY") + if not api_key: + raise ValueError("OpenAI API 키를 가져올 수 없습니다. 백엔드와 환경 변수 모두 확인해주세요.") + return api_key + + async def get_llm(self) -> ChatOpenAI: + """LLM 인스턴스를 비동기적으로 반환합니다.""" + if self._llm is None: + self._llm = await self._create_llm() + return self._llm + + async def _create_llm(self) -> ChatOpenAI: + """ChatOpenAI 인스턴스를 생성합니다.""" + try: + # API 키를 비동기적으로 로드 + api_key = await self._load_api_key() + + llm = ChatOpenAI( + model=self.model_name, + temperature=self.temperature, + api_key=api_key + ) + return llm + + except Exception as e: + raise RuntimeError(f"LLM 인스턴스 생성 실패: {e}") + + def update_model(self, model_name: str, temperature: float = None): + """모델 설정을 업데이트하고 인스턴스를 재생성합니다.""" + self.model_name = model_name + if temperature is not None: + self.temperature = temperature + self._llm = None # 다음 호출 시 재생성되도록 함 + + async def refresh_api_key(self): + """API 키를 새로고침합니다.""" + self._api_key = None + self._llm = None # LLM 인스턴스도 재생성 + logger.info("API key refreshed") + + async def test_connection(self) -> bool: + """LLM 연결을 테스트합니다.""" + try: + llm = await self.get_llm() + test_response = await llm.ainvoke("테스트") + return test_response is not None + + except Exception as e: + print(f"LLM 연결 테스트 실패: {e}") + return False + +# 싱글톤 인스턴스 +_llm_provider = LLMProvider() + +async def get_llm_provider() -> LLMProvider: + """LLM Provider 인스턴스를 반환합니다.""" + return _llm_provider From da39b4fdab332c827ab07f6ec99d8fe43769bbbe Mon Sep 17 00:00:00 2001 From: CheckerBoard Date: Sun, 17 Aug 2025 18:02:00 +0900 Subject: [PATCH 07/30] =?UTF-8?q?refactor:=20db=5Fmanager=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/core/db_manager.py | 40 ---------------------------------------- 1 file changed, 40 deletions(-) delete mode 100644 src/core/db_manager.py diff --git a/src/core/db_manager.py b/src/core/db_manager.py deleted file mode 100644 index c06ee1a..0000000 --- a/src/core/db_manager.py +++ /dev/null @@ -1,40 +0,0 @@ -# src/core/db_manager.py - -from langchain_community.utilities import SQLDatabase -import os -from dotenv import load_dotenv - -load_dotenv() - -def get_db_connection() -> SQLDatabase: - """SQLDatabase 객체를 생성하고 반환합니다. - - Args: - - Returns: - SQLDatabase: DB와 연결된 SQLDatabase 객체 - """ - db_uri = os.getenv("MYSQL_URI") - if not db_uri: - raise ValueError("DATABASE_URI 환경 변수가 .env 파일에 설정되지 않았습니다.") - return SQLDatabase.from_uri(db_uri) - -def load_predefined_schema(db: SQLDatabase) -> str: - """SQLDatabase 객체를 사용하여 모든 테이블의 스키마 정보를 반환합니다. - - Args: - db: DB와 연결된 SQLDatabase 객체 - - Returns: - str: DB에 포함된 모든 Table의 schema - - """ - try: - all_table_names = db.get_usable_table_names() - return db.get_table_info(table_names=all_table_names) - except Exception as e: - return f"스키마 조회 중 오류 발생: {e}" - -# 앱 전체에서 동일한 객체를 참조(싱글턴 패턴) -db_instance = get_db_connection() -schema_instance = load_predefined_schema(db_instance) From ed0c73be34264f15b51835632d5433deaef34a2a Mon Sep 17 00:00:00 2001 From: CheckerBoard Date: Sun, 17 Aug 2025 18:02:17 +0900 Subject: [PATCH 08/30] =?UTF-8?q?refactor:=20=ED=97=AC=EC=8A=A4=EC=B2=B4?= =?UTF-8?q?=ED=81=AC=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/health_check/router.py | 9 --------- 1 file changed, 9 deletions(-) delete mode 100644 src/health_check/router.py diff --git a/src/health_check/router.py b/src/health_check/router.py deleted file mode 100644 index 8fbbb2e..0000000 --- a/src/health_check/router.py +++ /dev/null @@ -1,9 +0,0 @@ -# src/health_check/router.py -from flask import Flask, jsonify - -app = Flask(__name__) - -@app.route("/health") -def health_check(): - """헬스체크 엔드포인트, 서버 상태가 정상이면 'ok'를 반환합니다.""" - return jsonify(status="ok"), 200 \ No newline at end of file From 85cc1d18a83464c0fe45d644345ecf999d1c7765 Mon Sep 17 00:00:00 2001 From: CheckerBoard Date: Sun, 17 Aug 2025 18:02:47 +0900 Subject: [PATCH 09/30] =?UTF-8?q?refactor:=20=EB=8D=B0=EC=9D=B4=ED=84=B0?= =?UTF-8?q?=20=EB=AA=A8=EB=8D=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/schemas/__init__.py | 6 +++ src/schemas/{ => agent}/sql_schemas.py | 5 ++- src/schemas/api/__init__.py | 6 +++ src/schemas/api/annotator_schemas.py | 57 ++++++++++++++++++++++++++ src/schemas/api/chat_schemas.py | 18 ++++++++ 5 files changed, 90 insertions(+), 2 deletions(-) create mode 100644 src/schemas/__init__.py rename src/schemas/{ => agent}/sql_schemas.py (55%) create mode 100644 src/schemas/api/__init__.py create mode 100644 src/schemas/api/annotator_schemas.py create mode 100644 src/schemas/api/chat_schemas.py diff --git a/src/schemas/__init__.py b/src/schemas/__init__.py new file mode 100644 index 0000000..2aec2f4 --- /dev/null +++ b/src/schemas/__init__.py @@ -0,0 +1,6 @@ +""" +스키마 루트 패키지 +""" + + + diff --git a/src/schemas/sql_schemas.py b/src/schemas/agent/sql_schemas.py similarity index 55% rename from src/schemas/sql_schemas.py rename to src/schemas/agent/sql_schemas.py index 2201103..0fcbc2b 100644 --- a/src/schemas/sql_schemas.py +++ b/src/schemas/agent/sql_schemas.py @@ -1,6 +1,7 @@ -# src/schemas/sql_schemas.py +# src/schemas/agent/sql_schemas.py + from pydantic import BaseModel, Field class SqlQuery(BaseModel): """SQL 쿼리를 나타내는 Pydantic 모델""" - query: str = Field(description="생성된 SQL 쿼리") \ No newline at end of file + query: str = Field(description="생성된 SQL 쿼리") diff --git a/src/schemas/api/__init__.py b/src/schemas/api/__init__.py new file mode 100644 index 0000000..93577a6 --- /dev/null +++ b/src/schemas/api/__init__.py @@ -0,0 +1,6 @@ +""" +API 스키마 패키지 +""" + + + diff --git a/src/schemas/api/annotator_schemas.py b/src/schemas/api/annotator_schemas.py new file mode 100644 index 0000000..d13e73f --- /dev/null +++ b/src/schemas/api/annotator_schemas.py @@ -0,0 +1,57 @@ +# src/schemas/api/annotator_schemas.py + +from pydantic import BaseModel, Field +from typing import List, Dict, Any + +class Column(BaseModel): + """데이터베이스 컬럼 모델""" + column_name: str + data_type: str + +class Table(BaseModel): + """데이터베이스 테이블 모델""" + table_name: str + columns: List[Column] + sample_rows: List[Dict[str, Any]] + +class Relationship(BaseModel): + """테이블 관계 모델""" + from_table: str + from_columns: List[str] + to_table: str + to_columns: List[str] + +class Database(BaseModel): + """데이터베이스 모델""" + database_name: str + tables: List[Table] + relationships: List[Relationship] + +class AnnotationRequest(BaseModel): + """어노테이션 요청 모델""" + dbms_type: str + databases: List[Database] + +class AnnotatedColumn(Column): + """어노테이션이 추가된 컬럼 모델""" + description: str = Field(..., description="AI가 생성한 컬럼 설명") + +class AnnotatedTable(Table): + """어노테이션이 추가된 테이블 모델""" + description: str = Field(..., description="AI가 생성한 테이블 설명") + columns: List[AnnotatedColumn] + +class AnnotatedRelationship(Relationship): + """어노테이션이 추가된 관계 모델""" + description: str = Field(..., description="AI가 생성한 관계 설명") + +class AnnotatedDatabase(Database): + """어노테이션이 추가된 데이터베이스 모델""" + description: str = Field(..., description="AI가 생성한 데이터베이스 설명") + tables: List[AnnotatedTable] + relationships: List[AnnotatedRelationship] + +class AnnotationResponse(BaseModel): + """어노테이션 응답 모델""" + dbms_type: str + databases: List[AnnotatedDatabase] diff --git a/src/schemas/api/chat_schemas.py b/src/schemas/api/chat_schemas.py new file mode 100644 index 0000000..c18d956 --- /dev/null +++ b/src/schemas/api/chat_schemas.py @@ -0,0 +1,18 @@ +# src/schemas/api/chat_schemas.py + +from pydantic import BaseModel +from typing import List, Optional + +class ChatMessage(BaseModel): + """대화 기록의 단일 메시지를 나타내는 모델""" + role: str # "user" 또는 "assistant" + content: str + +class ChatRequest(BaseModel): + """채팅 요청 모델""" + question: str + chat_history: Optional[List[ChatMessage]] = None + +class ChatResponse(BaseModel): + """채팅 응답 모델""" + answer: str From 08c533a9de380f0b5a2bf3130c64314459791088 Mon Sep 17 00:00:00 2001 From: CheckerBoard Date: Sun, 17 Aug 2025 18:03:05 +0900 Subject: [PATCH 10/30] =?UTF-8?q?refactor:=20=EC=96=B4=EB=85=B8=ED=85=8C?= =?UTF-8?q?=EC=9D=B4=EC=85=98=20=EC=84=9C=EB=B9=84=EC=8A=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/services/annotation/__init__.py | 6 + src/services/annotation/annotation_service.py | 260 ++++++++++++++++++ src/services/annotation_service.py | 100 ------- 3 files changed, 266 insertions(+), 100 deletions(-) create mode 100644 src/services/annotation/__init__.py create mode 100644 src/services/annotation/annotation_service.py delete mode 100644 src/services/annotation_service.py diff --git a/src/services/annotation/__init__.py b/src/services/annotation/__init__.py new file mode 100644 index 0000000..85f7091 --- /dev/null +++ b/src/services/annotation/__init__.py @@ -0,0 +1,6 @@ +""" +어노테이션 서비스 패키지 +""" + + + diff --git a/src/services/annotation/annotation_service.py b/src/services/annotation/annotation_service.py new file mode 100644 index 0000000..356e3f9 --- /dev/null +++ b/src/services/annotation/annotation_service.py @@ -0,0 +1,260 @@ +# src/services/annotation/annotation_service.py + +import asyncio +from typing import List, Dict, Any +from langchain_core.prompts import ChatPromptTemplate +from langchain_core.output_parsers import StrOutputParser + +from schemas.api.annotator_schemas import ( + AnnotationRequest, AnnotationResponse, + Database, Table, Column, Relationship, + AnnotatedDatabase, AnnotatedTable, AnnotatedColumn, AnnotatedRelationship +) +from core.providers.llm_provider import LLMProvider, get_llm_provider +import logging + +logger = logging.getLogger(__name__) + +class AnnotationService: + """어노테이션 생성과 관련된 모든 비즈니스 로직을 담당하는 서비스 클래스""" + + def __init__(self, llm_provider: LLMProvider = None): + self.llm_provider = llm_provider + + async def _initialize_dependencies(self): + """필요한 의존성들을 초기화합니다.""" + if self.llm_provider is None: + self.llm_provider = await get_llm_provider() + + async def _generate_description(self, template: str, **kwargs) -> str: + """LLM을 비동기적으로 호출하여 설명을 생성하는 헬퍼 함수""" + try: + await self._initialize_dependencies() + + prompt = ChatPromptTemplate.from_template(template) + llm = await self.llm_provider.get_llm() + chain = prompt | llm | StrOutputParser() + + result = await chain.ainvoke(kwargs) + return result.strip() + + except Exception as e: + logger.error(f"Failed to generate description: {e}") + return f"설명 생성 실패: {e}" + + async def _annotate_column( + self, + table_name: str, + sample_rows: str, + column: Column + ) -> AnnotatedColumn: + """단일 컬럼을 비동기적으로 어노테이트합니다.""" + try: + column_desc = await self._generate_description( + """ + 테이블 '{table_name}'의 컬럼 '{column_name}'(타입: {data_type})의 역할을 한국어로 간결하게 설명해줘. + 샘플 데이터: {sample_rows} + """, + table_name=table_name, + column_name=column.column_name, + data_type=column.data_type, + sample_rows=sample_rows + ) + + return AnnotatedColumn( + **column.model_dump(), + description=column_desc + ) + + except Exception as e: + logger.error(f"Failed to annotate column {column.column_name}: {e}") + return AnnotatedColumn( + **column.model_dump(), + description=f"설명 생성 실패: {e}" + ) + + async def _annotate_table(self, db_name: str, table: Table) -> AnnotatedTable: + """단일 테이블과 그 컬럼들을 비동기적으로 어노테이트합니다.""" + try: + sample_rows_str = str(table.sample_rows[:3]) + + # 테이블 설명 생성과 모든 컬럼 설명을 동시에 병렬로 처리 + table_desc_task = self._generate_description( + "데이터베이스 '{db_name}'에 속한 테이블 '{table_name}'의 역할을 한국어로 간결하게 설명해줘.", + db_name=db_name, + table_name=table.table_name + ) + + column_tasks = [ + self._annotate_column(table.table_name, sample_rows_str, col) + for col in table.columns + ] + + # 모든 작업을 병렬 실행 + results = await asyncio.gather( + table_desc_task, + *column_tasks, + return_exceptions=True + ) + + # 결과 처리 + table_desc = results[0] if not isinstance(results[0], Exception) else "테이블 설명 생성 실패" + annotated_columns = [ + result for result in results[1:] + if not isinstance(result, Exception) + ] + + return AnnotatedTable( + **table.model_dump(exclude={'columns'}), + description=table_desc, + columns=annotated_columns + ) + + except Exception as e: + logger.error(f"Failed to annotate table {table.table_name}: {e}") + # 실패 시 기본 어노테이션 반환 + annotated_columns = [ + AnnotatedColumn(**col.model_dump(), description="설명 생성 실패") + for col in table.columns + ] + return AnnotatedTable( + **table.model_dump(exclude={'columns'}), + description=f"테이블 설명 생성 실패: {e}", + columns=annotated_columns + ) + + async def _annotate_relationship(self, relationship: Relationship) -> AnnotatedRelationship: + """단일 관계를 비동기적으로 어노테이트합니다.""" + try: + rel_desc = await self._generate_description( + """ + 테이블 '{from_table}'이(가) 테이블 '{to_table}'을(를) 참조하고 있습니다. + 이 관계를 한국어 문장으로 설명해줘. + """, + from_table=relationship.from_table, + to_table=relationship.to_table + ) + + return AnnotatedRelationship( + **relationship.model_dump(), + description=rel_desc + ) + + except Exception as e: + logger.error(f"Failed to annotate relationship: {e}") + return AnnotatedRelationship( + **relationship.model_dump(), + description=f"관계 설명 생성 실패: {e}" + ) + + async def generate_for_schema(self, request: AnnotationRequest) -> AnnotationResponse: + """입력된 스키마 전체에 대한 어노테이션을 비동기적으로 생성합니다.""" + try: + logger.info(f"Starting annotation generation for {len(request.databases)} databases") + + annotated_databases = [] + + for db in request.databases: + try: + # DB 설명, 모든 테이블, 모든 관계 설명을 동시에 병렬로 처리 + db_desc_task = self._generate_description( + "데이터베이스 '{db_name}'의 역할을 한국어로 간결하게 설명해줘.", + db_name=db.database_name + ) + + table_tasks = [ + self._annotate_table(db.database_name, table) + for table in db.tables + ] + + relationship_tasks = [ + self._annotate_relationship(rel) + for rel in db.relationships + ] + + # 모든 작업을 병렬 실행 + all_results = await asyncio.gather( + db_desc_task, + *table_tasks, + *relationship_tasks, + return_exceptions=True + ) + + # 결과 분리 + db_desc = all_results[0] if not isinstance(all_results[0], Exception) else "DB 설명 생성 실패" + + num_tables = len(table_tasks) + annotated_tables = [ + result for result in all_results[1:1+num_tables] + if not isinstance(result, Exception) + ] + + annotated_relationships = [ + result for result in all_results[1+num_tables:] + if not isinstance(result, Exception) + ] + + annotated_databases.append( + AnnotatedDatabase( + database_name=db.database_name, + description=db_desc, + tables=annotated_tables, + relationships=annotated_relationships + ) + ) + + logger.info(f"Completed annotation for database: {db.database_name}") + + except Exception as e: + logger.error(f"Failed to annotate database {db.database_name}: {e}") + # 실패한 데이터베이스도 기본값으로 포함 + annotated_databases.append( + AnnotatedDatabase( + database_name=db.database_name, + description=f"데이터베이스 어노테이션 생성 실패: {e}", + tables=[], + relationships=[] + ) + ) + + logger.info("Annotation generation completed successfully") + + return AnnotationResponse( + dbms_type=request.dbms_type, + databases=annotated_databases + ) + + except Exception as e: + logger.error(f"Failed to generate annotations: {e}") + # 전체 실패 시 기본 응답 반환 + return AnnotationResponse( + dbms_type=request.dbms_type, + databases=[] + ) + + async def health_check(self) -> Dict[str, Any]: + """어노테이션 서비스의 상태를 확인합니다.""" + try: + await self._initialize_dependencies() + + # LLM 연결 테스트 + llm_status = await self.llm_provider.test_connection() + + return { + "status": "healthy" if llm_status else "unhealthy", + "llm_provider": "connected" if llm_status else "disconnected" + } + + except Exception as e: + logger.error(f"Annotation service health check failed: {e}") + return { + "status": "unhealthy", + "error": str(e) + } + +# 싱글톤 인스턴스 +_annotation_service = AnnotationService() + +async def get_annotation_service() -> AnnotationService: + """Annotation Service 인스턴스를 반환합니다.""" + return _annotation_service diff --git a/src/services/annotation_service.py b/src/services/annotation_service.py deleted file mode 100644 index 4e0e823..0000000 --- a/src/services/annotation_service.py +++ /dev/null @@ -1,100 +0,0 @@ -# src/services/annotation_service.py - -import asyncio -from langchain_openai import ChatOpenAI -from langchain_core.prompts import ChatPromptTemplate -from langchain_core.output_parsers import StrOutputParser -from api.v1.schemas.annotator_schemas import ( - AnnotationRequest, AnnotationResponse, - Database, Table, Column, Relationship, - AnnotatedDatabase, AnnotatedTable, AnnotatedColumn, AnnotatedRelationship -) - -class AnnotationService(): - """ - 어노테이션 생성과 관련된 모든 비즈니스 로직을 담당하는 서비스 클래스. - LLM 호출을 비동기적으로 처리하여 성능을 최적화합니다. - """ - def __init__(self, llm: ChatOpenAI): - self.llm = llm - - async def _generate_description(self, template: str, **kwargs) -> str: - """LLM을 비동기적으로 호출하여 설명을 생성하는 헬퍼 함수""" - prompt = ChatPromptTemplate.from_template(template) - chain = prompt | self.llm | StrOutputParser() - return await chain.ainvoke(kwargs) - - async def _annotate_column(self, table_name: str, sample_rows: str, column: Column) -> AnnotatedColumn: - """단일 컬럼을 비동기적으로 어노테이트합니다.""" - column_desc = await self._generate_description( - """ - 테이블 '{table_name}'의 컬럼 '{column_name}'(타입: {data_type})의 역할을 한국어로 간결하게 설명해줘. - 샘플 데이터: {sample_rows} - """, - table_name=table_name, - column_name=column.column_name, - data_type=column.data_type, - sample_rows=sample_rows - ) - return AnnotatedColumn(**column.model_dump(), description=column_desc.strip()) - - async def _annotate_table(self, db_name: str, table: Table) -> AnnotatedTable: - """단일 테이블과 그 컬럼들을 비동기적으로 어노테이트합니다.""" - sample_rows_str = str(table.sample_rows[:3]) - - # 테이블 설명 생성과 모든 컬럼 설명을 동시에 병렬로 처리 - table_desc_task = self._generate_description( - "데이터베이스 '{db_name}'에 속한 테이블 '{table_name}'의 역할을 한국어로 간결하게 설명해줘.", - db_name=db_name, table_name=table.table_name - ) - column_tasks = [self._annotate_column(table.table_name, sample_rows_str, col) for col in table.columns] - - results = await asyncio.gather(table_desc_task, *column_tasks) - - table_desc = results[0].strip() - annotated_columns = results[1:] - - return AnnotatedTable(**table.model_dump(exclude={'columns'}), description=table_desc, columns=annotated_columns) - - async def _annotate_relationship(self, relationship: Relationship) -> AnnotatedRelationship: - """단일 관계를 비동기적으로 어노테이트합니다.""" - rel_desc = await self._generate_description( - """ - 테이블 '{from_table}'이(가) 테이블 '{to_table}'을(를) 참조하고 있습니다. - 이 관계를 한국어 문장으로 설명해줘. - """, - from_table=relationship.from_table, to_table=relationship.to_table - ) - return AnnotatedRelationship(**relationship.model_dump(), description=rel_desc.strip()) - - async def generate_for_schema(self, request: AnnotationRequest) -> AnnotationResponse: - """ - 입력된 스키마 전체에 대한 어노테이션을 비동기적으로 생성합니다. - """ - annotated_databases = [] - for db in request.databases: - # DB 설명, 모든 테이블, 모든 관계 설명을 동시에 병렬로 처리 - db_desc_task = self._generate_description( - "데이터베이스 '{db_name}'의 역할을 한국어로 간결하게 설명해줘.", - db_name=db.database_name - ) - - table_tasks = [self._annotate_table(db.database_name, table) for table in db.tables] - relationship_tasks = [self._annotate_relationship(rel) for rel in db.relationships] - - db_desc_result, *other_results = await asyncio.gather(db_desc_task, *table_tasks, *relationship_tasks) - - num_tables = len(table_tasks) - annotated_tables = other_results[:num_tables] - annotated_relationships = other_results[num_tables:] - - annotated_databases.append( - AnnotatedDatabase( - database_name=db.database_name, - description=db_desc_result.strip(), - tables=annotated_tables, - relationships=annotated_relationships - ) - ) - - return AnnotationResponse(dbms_type=request.dbms_type, databases=annotated_databases) From c435bbd2403ccb6bc4cfaf0fb7d052f8badc5f4d Mon Sep 17 00:00:00 2001 From: CheckerBoard Date: Sun, 17 Aug 2025 18:03:21 +0900 Subject: [PATCH 11/30] =?UTF-8?q?refactor:=20=EC=B1=97=EB=B4=87=20?= =?UTF-8?q?=EC=84=9C=EB=B9=84=EC=8A=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/services/chat/__init__.py | 6 ++ src/services/chat/chatbot_service.py | 141 +++++++++++++++++++++++++++ src/services/chatbot_service.py | 34 ------- 3 files changed, 147 insertions(+), 34 deletions(-) create mode 100644 src/services/chat/__init__.py create mode 100644 src/services/chat/chatbot_service.py delete mode 100644 src/services/chatbot_service.py diff --git a/src/services/chat/__init__.py b/src/services/chat/__init__.py new file mode 100644 index 0000000..3a90e18 --- /dev/null +++ b/src/services/chat/__init__.py @@ -0,0 +1,6 @@ +""" +챗 서비스 패키지 +""" + + + diff --git a/src/services/chat/chatbot_service.py b/src/services/chat/chatbot_service.py new file mode 100644 index 0000000..423b7fa --- /dev/null +++ b/src/services/chat/chatbot_service.py @@ -0,0 +1,141 @@ +# src/services/chat/chatbot_service.py + +import asyncio +from typing import List, Optional, Dict, Any +from langchain_core.messages import HumanMessage, AIMessage, BaseMessage + +from schemas.api.chat_schemas import ChatMessage +from agents.sql_agent.graph import SqlAgentGraph +from core.providers.llm_provider import LLMProvider, get_llm_provider +from services.database.database_service import DatabaseService, get_database_service +import logging + +logger = logging.getLogger(__name__) + +class ChatbotService: + """챗봇 관련 비즈니스 로직을 담당하는 서비스 클래스""" + + def __init__( + self, + llm_provider: LLMProvider = None, + database_service: DatabaseService = None + ): + self.llm_provider = llm_provider + self.database_service = database_service + self._sql_agent_graph: Optional[SqlAgentGraph] = None + + async def _initialize_dependencies(self): + """필요한 의존성들을 초기화합니다.""" + if self.llm_provider is None: + self.llm_provider = await get_llm_provider() + + if self.database_service is None: + self.database_service = await get_database_service() + + if self._sql_agent_graph is None: + self._sql_agent_graph = SqlAgentGraph( + self.llm_provider, + self.database_service + ) + + async def handle_request( + self, + user_question: str, + chat_history: Optional[List[ChatMessage]] = None + ) -> str: + """채팅 요청을 처리하고 응답을 반환합니다.""" + try: + # 의존성 초기화 + await self._initialize_dependencies() + + # 채팅 히스토리를 LangChain 메시지로 변환 + langchain_messages = await self._convert_chat_history(chat_history) + + # 초기 상태 구성 + initial_state = { + "question": user_question, + "chat_history": langchain_messages, + "validation_error_count": 0, + "execution_error_count": 0 + } + + # SQL Agent 그래프 실행 + final_state = await self._sql_agent_graph.run(initial_state) + + return final_state.get('final_response', "죄송합니다. 응답을 생성할 수 없습니다.") + + except Exception as e: + logger.error(f"Chat request handling failed: {e}") + return f"죄송합니다. 요청 처리 중 오류가 발생했습니다: {e}" + + async def _convert_chat_history( + self, + chat_history: Optional[List[ChatMessage]] + ) -> List[BaseMessage]: + """채팅 히스토리를 LangChain 메시지 형식으로 변환합니다.""" + langchain_messages: List[BaseMessage] = [] + + if chat_history: + for message in chat_history: + try: + if message.role == 'user': + langchain_messages.append(HumanMessage(content=message.content)) + elif message.role == 'assistant': + langchain_messages.append(AIMessage(content=message.content)) + except Exception as e: + logger.warning(f"Failed to convert message: {e}") + continue + + return langchain_messages + + async def health_check(self) -> Dict[str, Any]: + """챗봇 서비스의 상태를 확인합니다.""" + try: + await self._initialize_dependencies() + + # LLM 연결 테스트 + llm_status = await self.llm_provider.test_connection() + + # 데이터베이스 서비스 상태 확인 + db_status = await self.database_service.health_check() + + overall_status = llm_status and db_status + + return { + "status": "healthy" if overall_status else "unhealthy", + "llm_provider": "connected" if llm_status else "disconnected", + "database_service": "connected" if db_status else "disconnected" + } + + except Exception as e: + logger.error(f"Health check failed: {e}") + return { + "status": "unhealthy", + "error": str(e) + } + + async def get_available_databases(self) -> List[Dict[str, str]]: + """사용 가능한 데이터베이스 목록을 반환합니다.""" + try: + await self._initialize_dependencies() + databases = await self.database_service.get_available_databases() + + return [ + { + "name": db.database_name, + "description": db.description, + "connection": db.connection_name + } + for db in databases + ] + + except Exception as e: + logger.error(f"Failed to get available databases: {e}") + return [] + +# 싱글톤 인스턴스 +_chatbot_service = ChatbotService() + +async def get_chatbot_service() -> ChatbotService: + """Chatbot Service 인스턴스를 반환합니다.""" + return _chatbot_service diff --git a/src/services/chatbot_service.py b/src/services/chatbot_service.py deleted file mode 100644 index 3a17cfc..0000000 --- a/src/services/chatbot_service.py +++ /dev/null @@ -1,34 +0,0 @@ -# src/services/chatbot_service.py - -from agents.sql_agent_graph import sql_agent_app -from api.v1.schemas.chatbot_schemas import ChatMessage # --- 추가된 부분 --- -from langchain_core.messages import HumanMessage, AIMessage, BaseMessage # --- 추가된 부분 --- -from typing import List, Optional # --- 추가된 부분 --- -#from core.db_manager import schema_instance - -class ChatbotService(): - # TODO: schema API 요청 - # def __init__(self): - # self.db_schema = schema_instance - - def handle_request(self, user_question: str, chat_history: Optional[List[ChatMessage]] = None) -> dict: - - langchain_messages: List[BaseMessage] = [] - if chat_history: - for message in chat_history: - if message.role == 'user': - langchain_messages.append(HumanMessage(content=message.content)) - elif message.role == 'assistant': - langchain_messages.append(AIMessage(content=message.content)) - - initial_state = { - "question": user_question, - "chat_history": langchain_messages, - # "db_schema": self.db_schema, - "validation_error_count": 0, - "execution_error_count": 0 - } - - final_state = sql_agent_app.invoke(initial_state) - - return final_state['final_response'] \ No newline at end of file From 94be712c46519bf191eacc551a3ba0650ffaa1f0 Mon Sep 17 00:00:00 2001 From: CheckerBoard Date: Sun, 17 Aug 2025 18:03:42 +0900 Subject: [PATCH 12/30] =?UTF-8?q?refactor:=20database=20=EC=84=9C=EB=B9=84?= =?UTF-8?q?=EC=8A=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/services/__init__.py | 18 +++ src/services/database/__init__.py | 6 + src/services/database/database_service.py | 148 ++++++++++++++++++++++ 3 files changed, 172 insertions(+) create mode 100644 src/services/__init__.py create mode 100644 src/services/database/__init__.py create mode 100644 src/services/database/database_service.py diff --git a/src/services/__init__.py b/src/services/__init__.py new file mode 100644 index 0000000..5d88207 --- /dev/null +++ b/src/services/__init__.py @@ -0,0 +1,18 @@ +# src/services/__init__.py + +""" +서비스 계층 - 비즈니스 로직 구성 요소들 +""" + +from .chat.chatbot_service import ChatbotService, get_chatbot_service +from .annotation.annotation_service import AnnotationService, get_annotation_service +from .database.database_service import DatabaseService, get_database_service + +__all__ = [ + 'ChatbotService', + 'get_chatbot_service', + 'AnnotationService', + 'get_annotation_service', + 'DatabaseService', + 'get_database_service' +] diff --git a/src/services/database/__init__.py b/src/services/database/__init__.py new file mode 100644 index 0000000..435b095 --- /dev/null +++ b/src/services/database/__init__.py @@ -0,0 +1,6 @@ +""" +데이터베이스 서비스 패키지 +""" + + + diff --git a/src/services/database/database_service.py b/src/services/database/database_service.py new file mode 100644 index 0000000..07c647f --- /dev/null +++ b/src/services/database/database_service.py @@ -0,0 +1,148 @@ +# src/services/database/database_service.py + +import asyncio +from typing import List, Optional, Dict +from core.clients.api_client import APIClient, DatabaseInfo, get_api_client +import logging + +logger = logging.getLogger(__name__) + +class DatabaseService: + """데이터베이스 관련 비즈니스 로직을 담당하는 서비스 클래스""" + + def __init__(self, api_client: APIClient = None): + self.api_client = api_client + self._cached_databases: Optional[List[DatabaseInfo]] = None + self._cached_schemas: Dict[str, str] = {} + + async def _get_api_client(self) -> APIClient: + """API 클라이언트를 가져옵니다.""" + if self.api_client is None: + self.api_client = await get_api_client() + return self.api_client + + async def get_available_databases(self) -> List[DatabaseInfo]: + """사용 가능한 데이터베이스 목록을 가져옵니다.""" + try: + if self._cached_databases is None: + api_client = await self._get_api_client() + self._cached_databases = await api_client.get_available_databases() + logger.info(f"Cached {len(self._cached_databases)} databases") + + return self._cached_databases + + except Exception as e: + logger.error(f"Failed to fetch databases: {e}") + # 폴백: 하드코딩된 데이터 반환 + return await self._get_fallback_databases() + + async def get_schema_for_db(self, db_name: str) -> str: + """특정 데이터베이스의 스키마를 가져옵니다.""" + try: + if db_name not in self._cached_schemas: + api_client = await self._get_api_client() + schema = await api_client.get_database_schema(db_name) + self._cached_schemas[db_name] = schema + logger.info(f"Cached schema for database: {db_name}") + + return self._cached_schemas[db_name] + + except Exception as e: + logger.error(f"Failed to fetch schema for {db_name}: {e}") + # 폴백: 기본 스키마 반환 + return await self.get_fallback_schema(db_name) + + async def execute_query(self, sql_query: str, database_name: str = None) -> str: + """SQL 쿼리를 실행하고 결과를 반환합니다.""" + try: + if not database_name: + logger.warning("Database name not provided, using default") + database_name = "default" + + logger.info(f"Executing SQL query on database '{database_name}': {sql_query}") + + api_client = await self._get_api_client() + response = await api_client.execute_query( + sql_query=sql_query, + database_name=database_name, + timeout=30 + ) + + if response.success: + result = response.result or "쿼리 실행 결과가 없습니다." + if response.execution_time: + logger.info(f"Query executed successfully in {response.execution_time}s") + if response.row_count is not None: + logger.info(f"Query returned {response.row_count} rows") + return result + else: + error_msg = response.error or "알 수 없는 오류" + logger.error(f"Query execution failed: {error_msg}") + return f"쿼리 실행 실패: {error_msg}" + + except Exception as e: + logger.error(f"Error during query execution: {e}") + return f"쿼리 실행 중 오류 발생: {e}" + + async def _get_fallback_databases(self) -> List[DatabaseInfo]: + """API 실패 시 사용할 폴백 데이터베이스 목록""" + return [ + DatabaseInfo( + connection_name="local_mysql", + database_name="sakila", + description="DVD 대여점 비즈니스 모델을 다루는 샘플 데이터베이스" + ), + DatabaseInfo( + connection_name="local_mysql", + database_name="ecom_prod", + description="온라인 쇼핑몰의 운영 데이터베이스" + ), + DatabaseInfo( + connection_name="local_mysql", + database_name="hr_analytics", + description="회사의 인사 관리 데이터베이스" + ), + DatabaseInfo( + connection_name="local_mysql", + database_name="web_logs", + description="웹사이트 트래픽 분석을 위한 로그 데이터베이스" + ) + ] + + async def get_fallback_schema(self, db_name: str) -> str: + """API 실패 시 사용할 폴백 스키마""" + fallback_schemas = { + "sakila": "CREATE TABLE actor (actor_id INT, first_name VARCHAR(45), last_name VARCHAR(45))", + "ecom_prod": "CREATE TABLE products (product_id INT, name VARCHAR(100), price DECIMAL(10,2))", + "hr_analytics": "CREATE TABLE employees (employee_id INT, name VARCHAR(100), department VARCHAR(50))", + "web_logs": "CREATE TABLE access_logs (log_id INT, timestamp DATETIME, ip_address VARCHAR(45))" + } + return fallback_schemas.get(db_name, "Schema information not available") + + async def refresh_cache(self): + """캐시를 새로고침합니다.""" + self._cached_databases = None + self._cached_schemas.clear() + logger.info("Database cache refreshed") + + async def clear_cache(self): + """캐시를 클리어합니다.""" + self._cached_databases = None + self._cached_schemas.clear() + logger.info("Database cache cleared") + + async def health_check(self) -> bool: + """데이터베이스 서비스 상태를 확인합니다.""" + try: + api_client = await self._get_api_client() + return await api_client.health_check() + except Exception as e: + logger.error(f"Database service health check failed: {e}") + return False + +# 싱글톤 인스턴스 +_database_service = DatabaseService() + +async def get_database_service() -> DatabaseService: + """Database Service 인스턴스를 반환합니다.""" + return _database_service From 74ca3655be36760e9d6185242e39ba77c8d36861 Mon Sep 17 00:00:00 2001 From: CheckerBoard Date: Sun, 17 Aug 2025 18:04:11 +0900 Subject: [PATCH 13/30] =?UTF-8?q?refactor:=20=EA=B3=A0=EC=A0=95=ED=8F=AC?= =?UTF-8?q?=ED=8A=B8=20=EB=B0=8F=20=EB=A1=9C=EA=B7=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main.py | 101 ++++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 75 insertions(+), 26 deletions(-) diff --git a/src/main.py b/src/main.py index 94fc27a..d023e72 100644 --- a/src/main.py +++ b/src/main.py @@ -1,51 +1,100 @@ # src/main.py -import socket -from contextlib import closing -import uvicorn +import logging +from contextlib import asynccontextmanager from fastapi import FastAPI -from api.v1.endpoints import chat, annotator -# def find_free_port(): -# """사용 가능한 비어있는 포트를 찾는 함수""" -# with closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as s: -# s.bind(('', 0)) -# s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) -# return s.getsockname()[1] +from api.v1.routers import chat, annotator, health + +# 로깅 설정 +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' +) +logger = logging.getLogger(__name__) + +@asynccontextmanager +async def lifespan(app: FastAPI): + """애플리케이션 라이프사이클 관리""" + logger.info("QGenie AI Chatbot 시작 중...") + + # 시작 시 초기화 작업 + try: + # 필요한 경우 여기에 초기화 로직 추가 + logger.info("애플리케이션 초기화 완료") + yield + finally: + # 종료 시 정리 작업 + logger.info("애플리케이션 종료 중...") + + # API 클라이언트 정리 + try: + from core.clients.api_client import get_api_client + api_client = await get_api_client() + await api_client.close() + logger.info("API 클라이언트 정리 완료") + except Exception as e: + logger.error(f"API 클라이언트 정리 실패: {e}") + + logger.info("애플리케이션 종료 완료") # FastAPI 앱 인스턴스 생성 app = FastAPI( - title="Qgenie - Agentic SQL Chatbot", - description="LangGraph로 구현된 사전 스키마를 지원하는 SQL 챗봇", - version="1.0.0" + title="QGenie - Agentic SQL Chatbot", + description="LangGraph로 구현된 사전 스키마를 지원하는 SQL 챗봇 (리팩터링 버전)", + version="2.0.0", + lifespan=lifespan +) + +# 라우터 등록 +app.include_router( + health.router, + prefix="/api/v1", + tags=["Health"] ) -# '/api/v1' 경로에 chat 라우터 포함 app.include_router( chat.router, prefix="/api/v1", tags=["Chatbot"] ) -# '/api/v1' 경로에 annotator 라우터 포함 app.include_router( annotator.router, prefix="/api/v1", tags=["Annotator"] ) +# 루트 엔드포인트 @app.get("/") -def health_check(): - """헬스체크 엔드포인트, 서버 상태가 정상이면 'ok' 반환합니다.""" - return {"status": "ok", "message": "Welcome to the QGenie Chatbot AI!"} +async def root(): + """루트 엔드포인트 - 기본 상태 확인""" + return { + "status": "ok", + "message": "Welcome to the QGenie Chatbot AI! (Refactored)", + "version": "2.0.0", + "endpoints": { + "chat": "/api/v1/chat", + "annotator": "/api/v1/annotator", + "health": "/api/v1/health", + "detailed_health": "/api/v1/health/detailed" + } + } if __name__ == "__main__": - # 1. 비어있는 포트 동적 할당 - # free_port = find_free_port() - free_port = 35816 # 포트 번호 고정 - - # 2. 할당된 포트 번호를 콘솔에 특정 형식으로 출력 + import uvicorn + + # 포트 번호 고정 (기존 설정 유지) + free_port = 35816 + + # 할당된 포트 번호를 콘솔에 특정 형식으로 출력 (Electron 연동을 위해) print(f"PYTHON_SERVER_PORT:{free_port}") - - # 3. 할당된 포트로 FastAPI 서버 실행 - uvicorn.run(app, host="127.0.0.1", port=free_port, reload=False) \ No newline at end of file + + # FastAPI 서버 실행 + uvicorn.run( + app, + host="127.0.0.1", + port=free_port, + reload=False, + log_level="info" + ) \ No newline at end of file From 70a2dd1155b449814d53ceaac87780d449337bc0 Mon Sep 17 00:00:00 2001 From: CheckerBoard Date: Sun, 17 Aug 2025 21:34:40 +0900 Subject: [PATCH 14/30] =?UTF-8?q?refactor:=20=EC=BF=BC=EB=A6=AC=20?= =?UTF-8?q?=EC=8B=A4=ED=96=89=20=EC=9A=94=EC=B2=AD=20=EB=B0=8F=20=EC=9D=91?= =?UTF-8?q?=EB=8B=B5=20=EB=AA=A8=EB=8D=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/core/clients/api_client.py | 76 +++++++++-------------- src/services/database/database_service.py | 21 +++---- 2 files changed, 38 insertions(+), 59 deletions(-) diff --git a/src/core/clients/api_client.py b/src/core/clients/api_client.py index e5c1dce..2b14120 100644 --- a/src/core/clients/api_client.py +++ b/src/core/clients/api_client.py @@ -17,18 +17,15 @@ class DatabaseInfo(BaseModel): class QueryExecutionRequest(BaseModel): """쿼리 실행 요청 모델""" - sql_query: str - database_name: str - execution_timeout: int = 30 - user_id: Optional[str] = None + user_db_id: str + database: str + query_text: str class QueryExecutionResponse(BaseModel): """쿼리 실행 응답 모델""" - success: bool - result: Optional[str] = None - error: Optional[str] = None - execution_time: Optional[float] = None - row_count: Optional[int] = None + code: str + message: str + data: bool class APIClient: """백엔드 API와 통신하는 클라이언트 클래스""" @@ -51,7 +48,7 @@ async def close(self): """HTTP 클라이언트 연결을 닫습니다.""" if self._client and not self._client.is_closed: await self._client.aclose() - + # TODO: DB 어노테이션 조회 async def get_available_databases(self) -> List[DatabaseInfo]: """사용 가능한 데이터베이스 목록을 가져옵니다.""" try: @@ -76,7 +73,7 @@ async def get_available_databases(self) -> List[DatabaseInfo]: except Exception as e: logger.error(f"Unexpected error: {e}") raise - + # TODO: DB 스키마 조회 API 필요 async def get_database_schema(self, database_name: str) -> str: """특정 데이터베이스의 스키마 정보를 가져옵니다.""" try: @@ -106,65 +103,50 @@ async def execute_query( self, sql_query: str, database_name: str, - timeout: int = 30 + user_db_id: str = None ) -> QueryExecutionResponse: """SQL 쿼리를 Backend 서버에 전송하여 실행하고 결과를 받아옵니다.""" try: logger.info(f"Sending SQL query to backend: {sql_query}") request_data = QueryExecutionRequest( - sql_query=sql_query, - database_name=database_name, - execution_timeout=timeout + user_db_id=user_db_id, + database=database_name, + query_text=sql_query ) client = await self._get_client() response = await client.post( - f"{self.base_url}/api/query/execute/actions", + f"{self.base_url}/api/query/execute/test", json=request_data.model_dump(), headers=self.headers, - timeout=httpx.Timeout(timeout + 5) + timeout=httpx.Timeout(35.0) # 고정 타임아웃 ) - if response.status_code == 200: - data = response.json() - result = QueryExecutionResponse(**data) - logger.info(f"Query executed successfully in {result.execution_time}s") - return result + response.raise_for_status() # HTTP 에러 시 예외 발생 + + data = response.json() + result = QueryExecutionResponse(**data) + + if result.code == "2400": + logger.info(f"Query executed successfully: {result.message}") else: - error_data = response.json() - error_msg = error_data.get("error", f"HTTP {response.status_code} error") - logger.error(f"Backend API error: {error_msg}") - - return QueryExecutionResponse( - success=False, - error=error_msg - ) + logger.warning(f"Query execution returned non-success code: {result.code} - {result.message}") + + return result except httpx.TimeoutException: logger.error("Backend API 요청 시간 초과") - return QueryExecutionResponse( - success=False, - error="쿼리 실행 시간 초과: Backend 서버 응답이 늦습니다." - ) + raise except httpx.ConnectError: logger.error("Backend 서버 연결 실패") - return QueryExecutionResponse( - success=False, - error="Backend 서버에 연결할 수 없습니다. 서버 상태를 확인해주세요." - ) + raise except httpx.HTTPStatusError as e: logger.error(f"HTTP error occurred: {e.response.status_code} - {e.response.text}") - return QueryExecutionResponse( - success=False, - error=f"HTTP 오류: {e.response.status_code}" - ) + raise except Exception as e: logger.error(f"Unexpected error during query execution: {e}") - return QueryExecutionResponse( - success=False, - error=f"쿼리 실행 중 예상치 못한 오류: {e}" - ) + raise async def health_check(self) -> bool: """API 서버 상태를 확인합니다.""" @@ -178,7 +160,7 @@ async def health_check(self) -> bool: except Exception as e: logger.error(f"Health check failed: {e}") return False - + # TODO: API 키 호출 API 필요 async def get_openai_api_key(self) -> str: """백엔드에서 OpenAI API 키를 가져옵니다.""" try: diff --git a/src/services/database/database_service.py b/src/services/database/database_service.py index 07c647f..69b99ea 100644 --- a/src/services/database/database_service.py +++ b/src/services/database/database_service.py @@ -52,7 +52,7 @@ async def get_schema_for_db(self, db_name: str) -> str: # 폴백: 기본 스키마 반환 return await self.get_fallback_schema(db_name) - async def execute_query(self, sql_query: str, database_name: str = None) -> str: + async def execute_query(self, sql_query: str, database_name: str = None, user_db_id: str = None) -> str: """SQL 쿼리를 실행하고 결과를 반환합니다.""" try: if not database_name: @@ -65,20 +65,17 @@ async def execute_query(self, sql_query: str, database_name: str = None) -> str: response = await api_client.execute_query( sql_query=sql_query, database_name=database_name, - timeout=30 + user_db_id=user_db_id ) - if response.success: - result = response.result or "쿼리 실행 결과가 없습니다." - if response.execution_time: - logger.info(f"Query executed successfully in {response.execution_time}s") - if response.row_count is not None: - logger.info(f"Query returned {response.row_count} rows") - return result + # 백엔드 응답 코드 확인 + if response.code == "2400": + logger.info(f"Query executed successfully: {response.message}") + return "쿼리가 성공적으로 실행되었습니다." else: - error_msg = response.error or "알 수 없는 오류" - logger.error(f"Query execution failed: {error_msg}") - return f"쿼리 실행 실패: {error_msg}" + error_msg = f"쿼리 실행 실패: {response.message} (코드: {response.code})" + logger.error(error_msg) + return error_msg except Exception as e: logger.error(f"Error during query execution: {e}") From fcb8fe659a27ae029521b238b999c91b921c9dae Mon Sep 17 00:00:00 2001 From: CheckerBoard Date: Sun, 17 Aug 2025 21:34:51 +0900 Subject: [PATCH 15/30] =?UTF-8?q?refactor:=20API=20=ED=82=A4=20=ED=98=B8?= =?UTF-8?q?=EC=B6=9C=20=EB=A1=9C=EC=A7=81=20=EC=88=98=EC=A0=95=20=EB=B0=8F?= =?UTF-8?q?=20=EC=98=88=EC=99=B8=20=EC=B2=98=EB=A6=AC=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/core/providers/llm_provider.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/core/providers/llm_provider.py b/src/core/providers/llm_provider.py index 08abb3d..c5407b2 100644 --- a/src/core/providers/llm_provider.py +++ b/src/core/providers/llm_provider.py @@ -34,12 +34,9 @@ async def _load_api_key(self) -> str: return self._api_key except Exception as e: - # 백엔드 실패 시 환경 변수로 폴백 + # API 키 호출 실패 시 에러 발생 logger.warning(f"Failed to fetch API key from backend: {e}, falling back to environment variable") - api_key = os.getenv("OPENAI_API_KEY") - if not api_key: - raise ValueError("OpenAI API 키를 가져올 수 없습니다. 백엔드와 환경 변수 모두 확인해주세요.") - return api_key + raise ValueError("OpenAI API 키를 가져올 수 없습니다.") async def get_llm(self) -> ChatOpenAI: """LLM 인스턴스를 비동기적으로 반환합니다.""" From d112991b06a57190ae7030d78f09fcb8599e9795 Mon Sep 17 00:00:00 2001 From: CheckerBoard Date: Sun, 17 Aug 2025 21:49:16 +0900 Subject: [PATCH 16/30] =?UTF-8?q?test:=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- test_services.py | 165 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 165 insertions(+) create mode 100644 test_services.py diff --git a/test_services.py b/test_services.py new file mode 100644 index 0000000..69e6182 --- /dev/null +++ b/test_services.py @@ -0,0 +1,165 @@ +#!/usr/bin/env python3 +""" +서비스 테스트 스크립트 +""" + +import asyncio +import sys +import os + +# src 디렉토리를 Python 경로에 추가 +sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'src')) + +async def test_llm_provider(): + """LLM Provider 테스트""" + print("🔍 LLM Provider 테스트 중...") + try: + from core.providers.llm_provider import get_llm_provider + + provider = await get_llm_provider() + print(f"✅ LLM Provider 생성 성공: {provider.model_name}") + + # 연결 테스트 + is_connected = await provider.test_connection() + print(f"🔗 LLM 연결 상태: {'성공' if is_connected else '실패'}") + + # API 키 소스 확인 (로그에서 확인 가능) + print("💡 API 키 소스는 로그에서 확인하세요 (fallback 사용 시 경고 메시지 표시)") + + except Exception as e: + print(f"❌ LLM Provider 테스트 실패: {e}") + +async def test_api_client(): + """API Client 테스트""" + print("\n🔍 API Client 테스트 중...") + try: + from core.clients.api_client import get_api_client + + client = await get_api_client() + print("✅ API Client 생성 성공") + + # OpenAI API 키 조회 테스트 + try: + api_key = await client.get_openai_api_key() + print(f"🔑 OpenAI API 키 조회 성공: {api_key[:20]}...") + except Exception as e: + print(f"⚠️ OpenAI API 키 조회 실패: {e}") + + # 헬스체크 테스트 + try: + is_healthy = await client.health_check() + print(f"🏥 백엔드 서버 상태: {'정상' if is_healthy else '비정상'}") + except Exception as e: + print(f"⚠️ 백엔드 서버 연결 실패: {e}") + + except Exception as e: + print(f"❌ API Client 테스트 실패: {e}") + +async def test_database_service(): + """Database Service 테스트""" + print("\n🔍 Database Service 테스트 중...") + try: + from services.database.database_service import get_database_service + + service = await get_database_service() + print("✅ Database Service 생성 성공") + + # 사용 가능한 데이터베이스 목록 조회 + try: + databases, is_fallback = await service.get_available_databases() + print(f"🗄️ 사용 가능한 데이터베이스: {len(databases)}개") + + if is_fallback: + print("⚠️ FALLBACK 사용됨: 하드코딩된 데이터베이스 목록") + else: + print("✅ 백엔드 API에서 데이터베이스 목록을 성공적으로 가져왔습니다") + + for db in databases[:3]: # 처음 3개만 출력 + print(f" - {db.database_name}: {db.description}") + except Exception as e: + print(f"⚠️ 데이터베이스 목록 조회 실패: {e}") + + except Exception as e: + print(f"❌ Database Service 테스트 실패: {e}") + +async def test_annotation_service(): + """Annotation Service 테스트""" + print("\n🔍 Annotation Service 테스트 중...") + try: + from services.annotation.annotation_service import get_annotation_service + + service = await get_annotation_service() + print("✅ Annotation Service 생성 성공") + + # 헬스체크 테스트 + try: + health = await service.health_check() + print(f"🏥 어노테이션 서비스 상태: {health}") + except Exception as e: + print(f"⚠️ 어노테이션 서비스 헬스체크 실패: {e}") + + except Exception as e: + print(f"❌ Annotation Service 테스트 실패: {e}") + +async def test_chatbot_service(): + """Chatbot Service 테스트""" + print("\n🔍 Chatbot Service 테스트 중...") + try: + from services.chat.chatbot_service import get_chatbot_service + + service = await get_chatbot_service() + print("✅ Chatbot Service 생성 성공") + + # 헬스체크 테스트 + try: + health = await service.health_check() + print(f"🏥 챗봇 서비스 상태: {health}") + except Exception as e: + print(f"⚠️ 챗봇 서비스 헬스체크 실패: {e}") + + except Exception as e: + print(f"❌ Chatbot Service 테스트 실패: {e}") + +async def test_sql_agent(): + """SQL Agent 테스트""" + print("\n🔍 SQL Agent 테스트 중...") + try: + from agents.sql_agent.graph import SqlAgentGraph + from core.providers.llm_provider import get_llm_provider + from services.database.database_service import get_database_service + + llm_provider = await get_llm_provider() + db_service = await get_database_service() + + agent = SqlAgentGraph(llm_provider, db_service) + print("✅ SQL Agent 생성 성공") + + # 그래프 시각화 PNG 저장 + try: + success = agent.save_graph_visualization("sql_agent_workflow.png") + if success: + print("📊 그래프 시각화 PNG 저장 성공: sql_agent_workflow.png") + else: + print("⚠️ 그래프 시각화 PNG 저장 실패") + except Exception as e: + print(f"⚠️ 그래프 시각화 생성 실패: {e}") + + except Exception as e: + print(f"❌ SQL Agent 테스트 실패: {e}") + +async def main(): + """메인 테스트 함수""" + print("🚀 QGenie AI 서비스 테스트 시작\n") + + # 각 서비스별 테스트 실행 + await test_llm_provider() + await test_api_client() + await test_database_service() + await test_annotation_service() + await test_chatbot_service() + await test_sql_agent() + + print("\n✨ 모든 테스트 완료!") + +if __name__ == "__main__": + asyncio.run(main()) From 065a39a28a9f5e78e824c1a899bda4300f75e831 Mon Sep 17 00:00:00 2001 From: CheckerBoard Date: Sun, 17 Aug 2025 21:50:05 +0900 Subject: [PATCH 17/30] =?UTF-8?q?feat:=20=EA=B7=B8=EB=9E=98=ED=94=84=20?= =?UTF-8?q?=EC=8B=9C=EA=B0=81=ED=99=94=20=EC=A0=80=EC=9E=A5=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/agents/sql_agent/graph.py | 26 ++++++++++++++++++-------- 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/src/agents/sql_agent/graph.py b/src/agents/sql_agent/graph.py index c69341e..f3f5858 100644 --- a/src/agents/sql_agent/graph.py +++ b/src/agents/sql_agent/graph.py @@ -110,13 +110,23 @@ async def run(self, initial_state: dict) -> dict: 'final_response': f"죄송합니다. 처리 중 오류가 발생했습니다: {e}" } - def get_graph_visualization(self) -> bytes: - """그래프의 시각화 이미지를 반환합니다.""" - if self._graph is None: - self.create_graph() - + def save_graph_visualization(self, file_path: str = "sql_agent_graph.png") -> bool: + """그래프 시각화를 파일로 저장합니다.""" try: - return self._graph.get_graph(xray=True).draw_mermaid_png() + if self._graph is None: + self.create_graph() + + # PNG 이미지 생성 + png_data = self._graph.get_graph(xray=True).draw_mermaid_png() + + # 파일로 저장 + with open(file_path, "wb") as f: + f.write(png_data) + + print(f"그래프 시각화가 {file_path}에 저장되었습니다.") + return True + except Exception as e: - print(f"그래프 시각화 생성 실패: {e}") - return None + print(f"그래프 시각화 저장 실패: {e}") + return False + \ No newline at end of file From a63cf2af9cfdb5151bf9d7f7cdee9f73b8266f7a Mon Sep 17 00:00:00 2001 From: CheckerBoard Date: Sun, 17 Aug 2025 21:50:16 +0900 Subject: [PATCH 18/30] =?UTF-8?q?refactor:=20API=20=ED=82=A4=20=EB=B0=8F?= =?UTF-8?q?=20=EB=8D=B0=EC=9D=B4=ED=84=B0=EB=B2=A0=EC=9D=B4=EC=8A=A4=20?= =?UTF-8?q?=ED=98=B8=EC=B6=9C=20=EB=A1=9C=EC=A7=81=20=EA=B0=9C=EC=84=A0,?= =?UTF-8?q?=20=ED=8F=B4=EB=B0=B1=20=EC=B2=98=EB=A6=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- sql_agent_workflow.png | Bin 0 -> 44245 bytes src/core/providers/llm_provider.py | 20 +++++++++++++++----- src/services/database/database_service.py | 12 ++++++------ 3 files changed, 21 insertions(+), 11 deletions(-) create mode 100644 sql_agent_workflow.png diff --git a/sql_agent_workflow.png b/sql_agent_workflow.png new file mode 100644 index 0000000000000000000000000000000000000000..cecf785244bc113b3547a6c5b56eab0f8d718e5d GIT binary patch literal 44245 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 str: """백엔드에서 OpenAI API 키를 로드합니다.""" @@ -30,13 +31,15 @@ async def _load_api_key(self) -> str: self._api_client = await get_api_client() self._api_key = await self._api_client.get_openai_api_key() - - return self._api_key + return self._api_key, False # 백엔드에서 가져옴 except Exception as e: - # API 키 호출 실패 시 에러 발생 + # 백엔드 실패 시 환경 변수로 폴백 logger.warning(f"Failed to fetch API key from backend: {e}, falling back to environment variable") - raise ValueError("OpenAI API 키를 가져올 수 없습니다.") + api_key = os.getenv("OPENAI_API_KEY") + if not api_key: + raise ValueError("OpenAI API 키를 가져올 수 없습니다. 백엔드와 환경 변수 모두 확인해주세요.") + return api_key, True # fallback 사용됨 async def get_llm(self) -> ChatOpenAI: """LLM 인스턴스를 비동기적으로 반환합니다.""" @@ -48,7 +51,14 @@ async def _create_llm(self) -> ChatOpenAI: """ChatOpenAI 인스턴스를 생성합니다.""" try: # API 키를 비동기적으로 로드 - api_key = await self._load_api_key() + api_key, is_fallback = await self._load_api_key() + + if is_fallback: + logger.warning("⚠️ 환경 변수에서 OpenAI API 키를 사용합니다 (fallback)") + self._api_key_fallback = True + else: + logger.info("✅ 백엔드에서 OpenAI API 키를 성공적으로 가져왔습니다") + self._api_key_fallback = False llm = ChatOpenAI( model=self.model_name, diff --git a/src/services/database/database_service.py b/src/services/database/database_service.py index 69b99ea..0b89a4a 100644 --- a/src/services/database/database_service.py +++ b/src/services/database/database_service.py @@ -28,13 +28,13 @@ async def get_available_databases(self) -> List[DatabaseInfo]: api_client = await self._get_api_client() self._cached_databases = await api_client.get_available_databases() logger.info(f"Cached {len(self._cached_databases)} databases") - - return self._cached_databases + return self._cached_databases, False # API에서 가져옴 except Exception as e: logger.error(f"Failed to fetch databases: {e}") # 폴백: 하드코딩된 데이터 반환 - return await self._get_fallback_databases() + fallback_data = await self._get_fallback_databases() + return fallback_data, True # fallback 사용됨 async def get_schema_for_db(self, db_name: str) -> str: """특정 데이터베이스의 스키마를 가져옵니다.""" @@ -44,13 +44,13 @@ async def get_schema_for_db(self, db_name: str) -> str: schema = await api_client.get_database_schema(db_name) self._cached_schemas[db_name] = schema logger.info(f"Cached schema for database: {db_name}") - - return self._cached_schemas[db_name] + return schema, False # API에서 가져옴 except Exception as e: logger.error(f"Failed to fetch schema for {db_name}: {e}") # 폴백: 기본 스키마 반환 - return await self.get_fallback_schema(db_name) + fallback_schema = await self.get_fallback_schema(db_name) + return fallback_schema, True # fallback 사용됨 async def execute_query(self, sql_query: str, database_name: str = None, user_db_id: str = None) -> str: """SQL 쿼리를 실행하고 결과를 반환합니다.""" From e8f4d972b13de642e4eb641884affccf3c4f5b82 Mon Sep 17 00:00:00 2001 From: CheckerBoard Date: Sun, 17 Aug 2025 22:01:41 +0900 Subject: [PATCH 19/30] =?UTF-8?q?feat:=20=EC=9D=98=EB=8F=84=20=EB=B6=84?= =?UTF-8?q?=EB=A5=98=20=EA=B8=B0=EB=8A=A5=20=EA=B0=9C=EC=84=A0=20=EB=B0=8F?= =?UTF-8?q?=20=EC=B1=84=ED=8C=85=20=EB=82=B4=EC=97=AD=20=ED=99=9C=EC=9A=A9?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/agents/sql_agent/nodes.py | 11 ++++++++++- src/prompts/v1/sql_agent/intent_classifier.yaml | 13 +++++++++++-- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/src/agents/sql_agent/nodes.py b/src/agents/sql_agent/nodes.py index 408f945..c11dcf2 100644 --- a/src/agents/sql_agent/nodes.py +++ b/src/agents/sql_agent/nodes.py @@ -66,9 +66,18 @@ async def intent_classifier_node(self, state: SqlAgentState) -> SqlAgentState: try: llm = await self.llm_provider.get_llm() + + # 채팅 내역을 활용하여 의도 분류 + input_data = { + "question": state['question'], + "chat_history": state.get('chat_history', []) + } + chain = self.intent_classifier_prompt | llm | StrOutputParser() - intent = await chain.ainvoke({"question": state['question']}) + intent = await chain.ainvoke(input_data) state['intent'] = intent.strip() + + print(f"의도 분류 결과: {state['intent']}") return state except Exception as e: diff --git a/src/prompts/v1/sql_agent/intent_classifier.yaml b/src/prompts/v1/sql_agent/intent_classifier.yaml index 1c2be08..9aa3a34 100644 --- a/src/prompts/v1/sql_agent/intent_classifier.yaml +++ b/src/prompts/v1/sql_agent/intent_classifier.yaml @@ -2,6 +2,7 @@ _type: prompt input_variables: - question + - chat_history template: | You are an intelligent assistant responsible for classifying user questions. Your task is to determine whether a user's question is related to retrieving information from a database using SQL. @@ -9,6 +10,9 @@ template: | - If the question can be answered with a SQL query, respond with "SQL". - If the question is a simple greeting, a question about your identity, or anything that does not require database access, respond with "non-SQL". + Consider the chat history context when classifying the current question. + If the current question is a follow-up or continuation of a previous SQL-related conversation, classify it as "SQL". + Example 1: Question: "Show me the list of users who signed up last month." Classification: SQL @@ -25,6 +29,11 @@ template: | Question: "What is the weather like today?" Classification: non-SQL - Now, classify the following question: - Question: {question} + Example 5 (Follow-up): + Previous: "Show me sales data for January" + Current: "How about February?" + Classification: SQL (continuation of data query) + + Chat History: {chat_history} + Current Question: {question} Classification: From 843e86a38b334166b4223bc5809438fd07262244 Mon Sep 17 00:00:00 2001 From: CheckerBoard Date: Sun, 17 Aug 2025 22:15:11 +0900 Subject: [PATCH 20/30] =?UTF-8?q?chore:=20=EA=B8=B0=EC=A1=B4=20=ED=97=AC?= =?UTF-8?q?=EC=8A=A4=20=EC=B2=B4=ED=81=AC=20=EB=AA=A8=EB=93=88=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/health_check/__init__.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 src/health_check/__init__.py diff --git a/src/health_check/__init__.py b/src/health_check/__init__.py deleted file mode 100644 index e69de29..0000000 From 5d04e63be657e6bd5a8c99a85e077c96d0277946 Mon Sep 17 00:00:00 2001 From: CheckerBoard Date: Mon, 18 Aug 2025 21:47:42 +0900 Subject: [PATCH 21/30] =?UTF-8?q?refactor:=20=EC=97=90=EB=9F=AC=20?= =?UTF-8?q?=EC=B2=98=EB=A6=AC=20=EB=B0=A9=EC=8B=9D=20=EA=B0=9C=EC=84=A0=20?= =?UTF-8?q?-=20=EC=98=88=EC=99=B8=EB=A5=BC=20=EB=9D=BC=EC=9A=B0=ED=84=B0?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EC=B2=98=EB=A6=AC=ED=95=98=EB=8F=84?= =?UTF-8?q?=EB=A1=9D=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/services/chat/chatbot_service.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/services/chat/chatbot_service.py b/src/services/chat/chatbot_service.py index 423b7fa..93f39fa 100644 --- a/src/services/chat/chatbot_service.py +++ b/src/services/chat/chatbot_service.py @@ -66,7 +66,8 @@ async def handle_request( except Exception as e: logger.error(f"Chat request handling failed: {e}") - return f"죄송합니다. 요청 처리 중 오류가 발생했습니다: {e}" + # 에러 상황에서는 예외를 다시 발생시켜 라우터에서 HTTP 에러로 처리되도록 함 + raise e async def _convert_chat_history( self, From 92d1da9b5e719f2deb41baf688de0719b5467183 Mon Sep 17 00:00:00 2001 From: CheckerBoard Date: Mon, 18 Aug 2025 22:09:08 +0900 Subject: [PATCH 22/30] =?UTF-8?q?refactor:=20=EA=B7=B8=EB=9E=98=ED=94=84?= =?UTF-8?q?=20=EC=8B=A4=ED=96=89=20=EC=A4=91=20=EC=97=90=EB=9F=AC=20?= =?UTF-8?q?=EC=B2=98=EB=A6=AC=20=EB=B0=A9=EC=8B=9D=EC=9D=84=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0=ED=95=98=EC=97=AC=20=EC=98=88=EC=99=B8=EB=A5=BC=20?= =?UTF-8?q?=EC=83=81=EC=9C=84=20=EB=A0=88=EB=B2=A8=EB=A1=9C=20=EC=A0=84?= =?UTF-8?q?=EB=8B=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/agents/sql_agent/graph.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/agents/sql_agent/graph.py b/src/agents/sql_agent/graph.py index f3f5858..cf48421 100644 --- a/src/agents/sql_agent/graph.py +++ b/src/agents/sql_agent/graph.py @@ -104,11 +104,8 @@ async def run(self, initial_state: dict) -> dict: except Exception as e: print(f"그래프 실행 중 오류 발생: {e}") - # 에러 발생 시 기본 응답 반환 - return { - **initial_state, - 'final_response': f"죄송합니다. 처리 중 오류가 발생했습니다: {e}" - } + # 에러 발생 시 예외를 다시 발생시켜 상위 레벨에서 HTTP 에러로 처리되도록 함 + raise e def save_graph_visualization(self, file_path: str = "sql_agent_graph.png") -> bool: """그래프 시각화를 파일로 저장합니다.""" From fc6877b36fd044834f0763fa373f4c382f8700c1 Mon Sep 17 00:00:00 2001 From: CheckerBoard Date: Mon, 18 Aug 2025 23:44:41 +0900 Subject: [PATCH 23/30] =?UTF-8?q?feat:=20=EC=BF=BC=EB=A6=AC=20=EC=8B=A4?= =?UTF-8?q?=ED=96=89=20API=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/core/clients/api_client.py | 60 ++++++++++++++++++++++++++++++---- 1 file changed, 53 insertions(+), 7 deletions(-) diff --git a/src/core/clients/api_client.py b/src/core/clients/api_client.py index 2b14120..8a83dd5 100644 --- a/src/core/clients/api_client.py +++ b/src/core/clients/api_client.py @@ -2,7 +2,7 @@ import httpx import asyncio -from typing import List, Dict, Any, Optional +from typing import List, Dict, Any, Optional, Union from pydantic import BaseModel import logging @@ -21,11 +21,16 @@ class QueryExecutionRequest(BaseModel): database: str query_text: str +class QueryResultData(BaseModel): + """쿼리 실행 결과 데이터 모델""" + columns: List[str] + data: List[Dict[str, Any]] + class QueryExecutionResponse(BaseModel): """쿼리 실행 응답 모델""" code: str message: str - data: bool + data: Union[QueryResultData, str, bool] # 결과 데이터, 에러 메시지 class APIClient: """백엔드 API와 통신하는 클라이언트 클래스""" @@ -125,8 +130,25 @@ async def execute_query( response.raise_for_status() # HTTP 에러 시 예외 발생 - data = response.json() - result = QueryExecutionResponse(**data) + response_data = response.json() + + # data 필드 타입에 따라 처리 + raw_data = response_data.get("data") + parsed_data = raw_data + + # data가 객체 형태(쿼리 결과)인지 확인 + if isinstance(raw_data, dict) and "columns" in raw_data and "data" in raw_data: + try: + parsed_data = QueryResultData(**raw_data) + except Exception as e: + logger.warning(f"Failed to parse query result data: {e}, using raw data") + parsed_data = raw_data + + result = QueryExecutionResponse( + code=response_data.get("code"), + message=response_data.get("message"), + data=parsed_data + ) if result.code == "2400": logger.info(f"Query executed successfully: {result.message}") @@ -160,11 +182,13 @@ async def health_check(self) -> bool: except Exception as e: logger.error(f"Health check failed: {e}") return False - # TODO: API 키 호출 API 필요 + async def get_openai_api_key(self) -> str: """백엔드에서 OpenAI API 키를 가져옵니다.""" try: client = await self._get_client() + + # 1단계: 암호화된 API 키 조회 response = await client.get( f"{self.base_url}/api/keys/find", headers=self.headers, @@ -187,8 +211,30 @@ async def get_openai_api_key(self) -> str: if not openai_key: raise ValueError("백엔드에서 OpenAI API 키를 찾을 수 없습니다.") - logger.info("Successfully fetched OpenAI API key from backend") - return openai_key + # 2단계: 복호화된 실제 API 키 조회 + decrypt_response = await client.get( + f"{self.base_url}/api/keys/find/decrypted/OpenAI", + headers=self.headers, + timeout=httpx.Timeout(10.0) + ) + decrypt_response.raise_for_status() + + decrypt_data = decrypt_response.json() + + # 복호화된 키 데이터에서 실제 API 키 추출 + decrypted_keys = decrypt_data.get("data", []) + actual_api_key = None + + for key_info in decrypted_keys: + if key_info.get("service_name") == "OpenAI": + actual_api_key = key_info.get("id") # 복호화된 실제 키 + break + + if not actual_api_key: + raise ValueError("백엔드에서 복호화된 OpenAI API 키를 가져올 수 없습니다.") + + logger.info("Successfully fetched decrypted OpenAI API key from backend") + return actual_api_key except httpx.HTTPStatusError as e: logger.error(f"HTTP error occurred while fetching API key: {e.response.status_code} - {e.response.text}") From 954373b5ba70360e295c4fc332cbc78363fa18fa Mon Sep 17 00:00:00 2001 From: CheckerBoard Date: Mon, 18 Aug 2025 23:46:25 +0900 Subject: [PATCH 24/30] =?UTF-8?q?refactor:=20SQL=20=EC=BF=BC=EB=A6=AC=20?= =?UTF-8?q?=EC=8B=A4=ED=96=89=20=EC=A4=91=20=EC=97=90=EB=9F=AC=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC=20=EA=B0=9C=EC=84=A0=20=EB=B0=8F=20=ED=8F=B4=EB=B0=B1?= =?UTF-8?q?=20=EB=A1=9C=EC=A7=81=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/agents/sql_agent/nodes.py | 22 ++++++++++++---- src/services/database/database_service.py | 32 ++++++++++++++++------- 2 files changed, 39 insertions(+), 15 deletions(-) diff --git a/src/agents/sql_agent/nodes.py b/src/agents/sql_agent/nodes.py index c11dcf2..b381c17 100644 --- a/src/agents/sql_agent/nodes.py +++ b/src/agents/sql_agent/nodes.py @@ -135,10 +135,11 @@ async def db_classifier_node(self, state: SqlAgentState) -> SqlAgentState: except Exception as e: print(f"데이터베이스 분류 실패: {e}") - # 폴백: 기본 데이터베이스 사용 - state['selected_db'] = 'sakila' - state['db_schema'] = await self.database_service.get_fallback_schema('sakila') - return state + print(f"에러 타입: {type(e).__name__}") + print(f"에러 상세: {str(e)}") + + # 폴백 없이 에러를 다시 발생시킴 + raise e async def sql_generator_node(self, state: SqlAgentState) -> SqlAgentState: """SQL 쿼리를 생성하는 노드""" @@ -233,9 +234,12 @@ async def sql_executor_node(self, state: SqlAgentState) -> SqlAgentState: try: selected_db = state.get('selected_db', 'default') + user_db_id = state.get('user_db_id', 'TEST-USER-DB-12345') + result = await self.database_service.execute_query( state['sql_query'], - database_name=selected_db + database_name=selected_db, + user_db_id=user_db_id ) state['execution_result'] = result @@ -250,7 +254,15 @@ async def sql_executor_node(self, state: SqlAgentState) -> SqlAgentState: state['validation_error_count'] = 0 state['execution_error_count'] = state.get('execution_error_count', 0) + 1 + print(f"⚠️ SQL 실행 실패 ({state['execution_error_count']}/{MAX_ERROR_COUNT}): {error_msg}") + if state['execution_error_count'] >= MAX_ERROR_COUNT: + print(f"🚫 SQL 실행 실패 {MAX_ERROR_COUNT}회 도달, 재시도 중단") + print(f"최종 에러: {error_msg}") + + # 최종 실패 시 기본 응답 설정 + state['final_response'] = f"죄송합니다. SQL 쿼리 실행에 실패했습니다. 오류: {error_msg}" + raise MaxRetryExceededException( f"SQL 실행 실패가 {MAX_ERROR_COUNT}회 반복됨", MAX_ERROR_COUNT ) diff --git a/src/services/database/database_service.py b/src/services/database/database_service.py index 0b89a4a..40d3f14 100644 --- a/src/services/database/database_service.py +++ b/src/services/database/database_service.py @@ -28,13 +28,12 @@ async def get_available_databases(self) -> List[DatabaseInfo]: api_client = await self._get_api_client() self._cached_databases = await api_client.get_available_databases() logger.info(f"Cached {len(self._cached_databases)} databases") - return self._cached_databases, False # API에서 가져옴 + + return self._cached_databases except Exception as e: logger.error(f"Failed to fetch databases: {e}") - # 폴백: 하드코딩된 데이터 반환 - fallback_data = await self._get_fallback_databases() - return fallback_data, True # fallback 사용됨 + raise RuntimeError(f"데이터베이스 목록을 가져올 수 없습니다. 백엔드 서버를 확인해주세요: {e}") async def get_schema_for_db(self, db_name: str) -> str: """특정 데이터베이스의 스키마를 가져옵니다.""" @@ -44,13 +43,12 @@ async def get_schema_for_db(self, db_name: str) -> str: schema = await api_client.get_database_schema(db_name) self._cached_schemas[db_name] = schema logger.info(f"Cached schema for database: {db_name}") - return schema, False # API에서 가져옴 + + return self._cached_schemas[db_name] except Exception as e: logger.error(f"Failed to fetch schema for {db_name}: {e}") - # 폴백: 기본 스키마 반환 - fallback_schema = await self.get_fallback_schema(db_name) - return fallback_schema, True # fallback 사용됨 + raise RuntimeError(f"데이터베이스 '{db_name}' 스키마를 가져올 수 없습니다. 백엔드 서버를 확인해주세요: {e}") async def execute_query(self, sql_query: str, database_name: str = None, user_db_id: str = None) -> str: """SQL 쿼리를 실행하고 결과를 반환합니다.""" @@ -71,9 +69,23 @@ async def execute_query(self, sql_query: str, database_name: str = None, user_db # 백엔드 응답 코드 확인 if response.code == "2400": logger.info(f"Query executed successfully: {response.message}") - return "쿼리가 성공적으로 실행되었습니다." + + # 응답 데이터 형태에 따라 다른 메시지 반환 + if hasattr(response.data, 'columns') and hasattr(response.data, 'data'): + # 쿼리 결과 데이터가 있는 경우 + row_count = len(response.data.data) + col_count = len(response.data.columns) + return f"쿼리가 성공적으로 실행되었습니다. {row_count}개 행, {col_count}개 컬럼의 결과를 반환했습니다." + else: + # 일반적인 성공 메시지 + return "쿼리가 성공적으로 실행되었습니다." else: - error_msg = f"쿼리 실행 실패: {response.message} (코드: {response.code})" + # data에 에러 메시지가 있는지 확인 + error_detail = "" + if isinstance(response.data, str): + error_detail = f" 상세: {response.data}" + + error_msg = f"쿼리 실행 실패: {response.message} (코드: {response.code}){error_detail}" logger.error(error_msg) return error_msg From cd5c4d2c25277bc9a81c5f9369f8b611533bb8d4 Mon Sep 17 00:00:00 2001 From: CheckerBoard Date: Mon, 18 Aug 2025 23:46:43 +0900 Subject: [PATCH 25/30] =?UTF-8?q?refactor:=20=EC=B1=84=ED=8C=85=20?= =?UTF-8?q?=EB=A9=94=EC=8B=9C=EC=A7=80=20=EC=97=AD=ED=95=A0=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/services/chat/chatbot_service.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/services/chat/chatbot_service.py b/src/services/chat/chatbot_service.py index 93f39fa..5f2d3ff 100644 --- a/src/services/chat/chatbot_service.py +++ b/src/services/chat/chatbot_service.py @@ -79,9 +79,9 @@ async def _convert_chat_history( if chat_history: for message in chat_history: try: - if message.role == 'user': + if message.role == 'u': langchain_messages.append(HumanMessage(content=message.content)) - elif message.role == 'assistant': + elif message.role == 'a': langchain_messages.append(AIMessage(content=message.content)) except Exception as e: logger.warning(f"Failed to convert message: {e}") From f760f50724e410b87adc2f74a539b813d7383c87 Mon Sep 17 00:00:00 2001 From: CheckerBoard Date: Mon, 18 Aug 2025 23:47:05 +0900 Subject: [PATCH 26/30] =?UTF-8?q?feat:=20api=20key=20=EC=A0=84=EB=8B=AC=20?= =?UTF-8?q?API=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/core/providers/llm_provider.py | 24 +++++------------------- 1 file changed, 5 insertions(+), 19 deletions(-) diff --git a/src/core/providers/llm_provider.py b/src/core/providers/llm_provider.py index 0a81de2..80e4c60 100644 --- a/src/core/providers/llm_provider.py +++ b/src/core/providers/llm_provider.py @@ -5,11 +5,8 @@ import logging from typing import Optional from langchain_openai import ChatOpenAI -from dotenv import load_dotenv from core.clients.api_client import get_api_client -load_dotenv() - logger = logging.getLogger(__name__) class LLMProvider: @@ -21,7 +18,6 @@ def __init__(self, model_name: str = "gpt-4o-mini", temperature: float = 0): self._llm: Optional[ChatOpenAI] = None self._api_key: Optional[str] = None self._api_client = None - self._api_key_fallback = False # fallback 사용 여부 추적 async def _load_api_key(self) -> str: """백엔드에서 OpenAI API 키를 로드합니다.""" @@ -31,15 +27,11 @@ async def _load_api_key(self) -> str: self._api_client = await get_api_client() self._api_key = await self._api_client.get_openai_api_key() - return self._api_key, False # 백엔드에서 가져옴 + return self._api_key except Exception as e: - # 백엔드 실패 시 환경 변수로 폴백 - logger.warning(f"Failed to fetch API key from backend: {e}, falling back to environment variable") - api_key = os.getenv("OPENAI_API_KEY") - if not api_key: - raise ValueError("OpenAI API 키를 가져올 수 없습니다. 백엔드와 환경 변수 모두 확인해주세요.") - return api_key, True # fallback 사용됨 + logger.error(f"Failed to fetch API key from backend: {e}") + raise ValueError("백엔드에서 OpenAI API 키를 가져올 수 없습니다. 백엔드 서버를 확인해주세요.") async def get_llm(self) -> ChatOpenAI: """LLM 인스턴스를 비동기적으로 반환합니다.""" @@ -51,14 +43,8 @@ async def _create_llm(self) -> ChatOpenAI: """ChatOpenAI 인스턴스를 생성합니다.""" try: # API 키를 비동기적으로 로드 - api_key, is_fallback = await self._load_api_key() - - if is_fallback: - logger.warning("⚠️ 환경 변수에서 OpenAI API 키를 사용합니다 (fallback)") - self._api_key_fallback = True - else: - logger.info("✅ 백엔드에서 OpenAI API 키를 성공적으로 가져왔습니다") - self._api_key_fallback = False + api_key = await self._load_api_key() + logger.info("✅ 백엔드에서 OpenAI API 키를 성공적으로 가져왔습니다") llm = ChatOpenAI( model=self.model_name, From 7b11ce1f049933c930b8ea1bc80f538b6b1ce1e4 Mon Sep 17 00:00:00 2001 From: CheckerBoard Date: Mon, 18 Aug 2025 23:47:27 +0900 Subject: [PATCH 27/30] =?UTF-8?q?refactor:=20=EC=96=B4=EB=85=B8=ED=85=8C?= =?UTF-8?q?=EC=9D=B4=EC=85=98=20=EB=AA=A8=EB=8D=B8=20=ED=81=B4=EB=9E=98?= =?UTF-8?q?=EC=8A=A4=20=EA=B5=AC=EC=A1=B0=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/schemas/api/annotator_schemas.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/schemas/api/annotator_schemas.py b/src/schemas/api/annotator_schemas.py index d13e73f..6dda6d7 100644 --- a/src/schemas/api/annotator_schemas.py +++ b/src/schemas/api/annotator_schemas.py @@ -32,12 +32,14 @@ class AnnotationRequest(BaseModel): dbms_type: str databases: List[Database] -class AnnotatedColumn(Column): +class AnnotatedColumn(BaseModel): """어노테이션이 추가된 컬럼 모델""" + column_name: str description: str = Field(..., description="AI가 생성한 컬럼 설명") -class AnnotatedTable(Table): +class AnnotatedTable(BaseModel): """어노테이션이 추가된 테이블 모델""" + table_name: str description: str = Field(..., description="AI가 생성한 테이블 설명") columns: List[AnnotatedColumn] @@ -45,8 +47,9 @@ class AnnotatedRelationship(Relationship): """어노테이션이 추가된 관계 모델""" description: str = Field(..., description="AI가 생성한 관계 설명") -class AnnotatedDatabase(Database): +class AnnotatedDatabase(BaseModel): """어노테이션이 추가된 데이터베이스 모델""" + database_name: str description: str = Field(..., description="AI가 생성한 데이터베이스 설명") tables: List[AnnotatedTable] relationships: List[AnnotatedRelationship] From cf290e02dffae66e88ebd21112564fdf90dbc0dd Mon Sep 17 00:00:00 2001 From: CheckerBoard Date: Mon, 18 Aug 2025 23:48:14 +0900 Subject: [PATCH 28/30] =?UTF-8?q?test:=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- test_services.py | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/test_services.py b/test_services.py index 69e6182..887140c 100644 --- a/test_services.py +++ b/test_services.py @@ -24,7 +24,7 @@ async def test_llm_provider(): print(f"🔗 LLM 연결 상태: {'성공' if is_connected else '실패'}") # API 키 소스 확인 (로그에서 확인 가능) - print("💡 API 키 소스는 로그에서 확인하세요 (fallback 사용 시 경고 메시지 표시)") + print("💡 백엔드에서 API 키를 가져옵니다") except Exception as e: print(f"❌ LLM Provider 테스트 실패: {e}") @@ -66,13 +66,9 @@ async def test_database_service(): # 사용 가능한 데이터베이스 목록 조회 try: - databases, is_fallback = await service.get_available_databases() + databases = await service.get_available_databases() print(f"🗄️ 사용 가능한 데이터베이스: {len(databases)}개") - - if is_fallback: - print("⚠️ FALLBACK 사용됨: 하드코딩된 데이터베이스 목록") - else: - print("✅ 백엔드 API에서 데이터베이스 목록을 성공적으로 가져왔습니다") + print("✅ 백엔드 API에서 데이터베이스 목록을 성공적으로 가져왔습니다") for db in databases[:3]: # 처음 3개만 출력 print(f" - {db.database_name}: {db.description}") From 2eb6d5307fb1dc9b11deedd344579565d150aa59 Mon Sep 17 00:00:00 2001 From: CheckerBoard Date: Tue, 19 Aug 2025 00:21:15 +0900 Subject: [PATCH 29/30] =?UTF-8?q?fix:=20API=20=ED=82=A4=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20=EA=B2=BD=EB=A1=9C=20=EC=88=98=EC=A0=95=20=EB=B0=8F?= =?UTF-8?q?=20=EB=B3=B5=ED=98=B8=ED=99=94=EB=90=9C=20=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=84=B0=20=EC=B2=98=EB=A6=AC=20=EB=A1=9C=EC=A7=81=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/core/clients/api_client.py | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/src/core/clients/api_client.py b/src/core/clients/api_client.py index 8a83dd5..1c2c9a4 100644 --- a/src/core/clients/api_client.py +++ b/src/core/clients/api_client.py @@ -190,7 +190,7 @@ async def get_openai_api_key(self) -> str: # 1단계: 암호화된 API 키 조회 response = await client.get( - f"{self.base_url}/api/keys/find", + f"{self.base_url}/api/keys/result", headers=self.headers, timeout=httpx.Timeout(10.0) ) @@ -222,13 +222,12 @@ async def get_openai_api_key(self) -> str: decrypt_data = decrypt_response.json() # 복호화된 키 데이터에서 실제 API 키 추출 - decrypted_keys = decrypt_data.get("data", []) - actual_api_key = None + data_field = decrypt_data.get("data", {}) - for key_info in decrypted_keys: - if key_info.get("service_name") == "OpenAI": - actual_api_key = key_info.get("id") # 복호화된 실제 키 - break + if isinstance(data_field, dict) and "api_key" in data_field: + actual_api_key = data_field["api_key"] + else: + raise ValueError("백엔드 응답에서 API 키를 찾을 수 없습니다.") if not actual_api_key: raise ValueError("백엔드에서 복호화된 OpenAI API 키를 가져올 수 없습니다.") From c61c13b6e30fcb50a3c88a4e286a4619b2e7ff44 Mon Sep 17 00:00:00 2001 From: CheckerBoard Date: Tue, 19 Aug 2025 00:21:22 +0900 Subject: [PATCH 30/30] =?UTF-8?q?feat:=20End-to-End=20=EC=B1=84=ED=8C=85?= =?UTF-8?q?=20=EB=B0=8F=20=EC=96=B4=EB=85=B8=ED=85=8C=EC=9D=B4=EC=85=98=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80,=20=EC=97=90=EB=9F=AC=20=EC=8B=9C=EB=82=98=EB=A6=AC?= =?UTF-8?q?=EC=98=A4=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- test_services.py | 105 ++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 104 insertions(+), 1 deletion(-) diff --git a/test_services.py b/test_services.py index 887140c..5b66de8 100644 --- a/test_services.py +++ b/test_services.py @@ -143,11 +143,100 @@ async def test_sql_agent(): except Exception as e: print(f"❌ SQL Agent 테스트 실패: {e}") +async def test_end_to_end_chat(): + """실제 채팅 요청 End-to-End 테스트""" + print("\n🔍 End-to-End 채팅 테스트 중...") + try: + from services.chat.chatbot_service import get_chatbot_service + import time + + service = await get_chatbot_service() + + # SQL 관련 질문으로 테스트 + test_questions = [ + "사용자 테이블에서 모든 데이터를 조회해주세요", + "가장 많이 주문한 고객을 찾아주세요", + ] + + for i, question in enumerate(test_questions, 1): + print(f"🤖 테스트 질문 {i}: {question}") + start_time = time.time() + + try: + response = await service.handle_request(user_question=question) + end_time = time.time() + response_time = round(end_time - start_time, 2) + + print(f"✅ 응답 시간: {response_time}초") + print(f"📝 응답: {response[:100]}{'...' if len(response) > 100 else ''}") + except Exception as e: + print(f"❌ 질문 {i} 실패: {e}") + + print("---") + + except Exception as e: + print(f"❌ End-to-End 테스트 실패: {e}") + +async def test_annotation_functionality(): + """어노테이션 기능 실제 사용 테스트""" + print("\n🔍 어노테이션 기능 테스트 중...") + try: + from services.annotation.annotation_service import get_annotation_service + from schemas.api.annotator_schemas import Database, Table, Column + + service = await get_annotation_service() + + # 샘플 데이터로 어노테이션 테스트 + sample_database = Database( + database_name="test_db", + tables=[ + Table( + table_name="users", + columns=[ + Column(column_name="id", data_type="int"), + Column(column_name="name", data_type="varchar"), + Column(column_name="email", data_type="varchar") + ], + sample_rows=["1, John Doe, john@example.com"] + ) + ], + relationships=[] + ) + + try: + result = await service.generate_annotations(sample_database) + print(f"✅ 어노테이션 생성 성공") + print(f"📝 생성된 테이블 수: {len(result.tables)}") + if result.tables: + print(f"📝 첫 번째 테이블 설명: {result.tables[0].description[:100]}...") + except Exception as e: + print(f"⚠️ 어노테이션 생성 실패: {e}") + + except Exception as e: + print(f"❌ 어노테이션 기능 테스트 실패: {e}") + +async def test_error_scenarios(): + """에러 시나리오 테스트""" + print("\n🔍 에러 시나리오 테스트 중...") + + # 잘못된 API 키로 LLM 테스트 + print("🧪 잘못된 API 키 시나리오...") + try: + from core.providers.llm_provider import LLMProvider + + # 일시적으로 잘못된 API 키 설정 테스트는 실제 환경에서는 위험하므로 스킵 + print("⚠️ 실제 환경에서는 API 키 에러 테스트 스킵") + + except Exception as e: + print(f"✅ 예상된 에러 발생: {e}") + + print("✅ 에러 시나리오 테스트 완료") + async def main(): """메인 테스트 함수""" print("🚀 QGenie AI 서비스 테스트 시작\n") - # 각 서비스별 테스트 실행 + # 기본 서비스 테스트 await test_llm_provider() await test_api_client() await test_database_service() @@ -155,6 +244,20 @@ async def main(): await test_chatbot_service() await test_sql_agent() + # 확장 테스트 (백엔드 연결이 가능한 경우에만) + try: + from core.clients.api_client import get_api_client + client = await get_api_client() + if await client.health_check(): + print("\n🧪 확장 테스트 시작 (백엔드 연결 확인됨)") + await test_end_to_end_chat() + await test_annotation_functionality() + await test_error_scenarios() + else: + print("\n⚠️ 백엔드 연결 불가 - 확장 테스트 스킵") + except Exception: + print("\n⚠️ 백엔드 연결 불가 - 확장 테스트 스킵") + print("\n✨ 모든 테스트 완료!") if __name__ == "__main__":