From e488a0afd824278b2d54759691598ed88ff0bbb2 Mon Sep 17 00:00:00 2001 From: ChoiseU Date: Sat, 5 Jul 2025 00:05:28 +0900 Subject: [PATCH 01/80] =?UTF-8?q?docs:=20=EB=B0=B0=ED=8F=AC=20=EB=B0=A9?= =?UTF-8?q?=EC=8B=9D=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 22 +++++++++------------- 1 file changed, 9 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index ab17ebd..c12b793 100644 --- a/README.md +++ b/README.md @@ -59,21 +59,17 @@ GitHub에서 새로운 태그를 발행하면 파이프라인이 자동으로 ~~~markdown 1. 모든 기능 개발과 테스트가 완료된 코드를 main 브랜치에 병합(Merge)합니다. -2. AskQL/AI 저장소의 Releases 탭으로 이동하여 Draft a new release 버튼을 클릭합니다. -3. Choose a tag 항목에서 v1.0.0과 같이 새로운 버전 태그를 입력하고 생성합니다. -4. (가장 중요 ⭐) Target 드롭다운 메뉴에서 반드시 main 브랜치를 선택합니다. -5. 릴리즈 노트를 작성하고 Publish release 버튼을 클릭합니다. -6. Target 드롭다운 메뉴에서 반드시 main 브랜치를 선택합니다. -7. 릴리즈 노트를 작성하고 Publish release 버튼을 클릭합니다. +2. 레포지토리에서 Releases 탭으로 이동하여 Create a new release 버튼을 클릭합니다. +3. Choose a tag 항목을 클릭한후 Find or create a new tag 부분에 버전(v1.0.0)과 같이 새로운 버전 태그를 입력하고 아래 Create new tag를 클릭하여 태그를 생성합니다. +4. ⭐중요) Target 드롭다운 메뉴에서 반드시 main 브랜치를 선택합니다. +5. 제목에 버전을 입력하고 릴리즈 노트를 작성합니다 +6. 🚨주의) Publish release 버튼을 클릭합니다. + 릴리즈 발행은 되돌릴 수 없습니다. + 잘못된 릴리즈는 서비스에 직접적인 영향을 줄 수 있으니, 반드시 팀의 승인을 받고 신중하게 진행해 주십시오. +7. Actions 탭에 들어가 파이프라인을 확인 후 정상 배포되었다면 App 레포에 develop 브랜치에서 실행 파일을 확인합니다. +8. 만약 실패하였다면 인프라 담당자에게 말해주세요. ~~~ -버튼을 클릭하는 즉시, 아래의 경고 사항에 설명된 자동 배포 프로세스가 시작됩니다. - -🛑 경고: 릴리즈 발행은 되돌릴 수 없습니다 -GitHub에서 새로운 릴리즈를 발행하면, 자동으로 프로덕션 배포가 시작됩니다. - -이 과정은 중간에 멈출 수 없으며, main 브랜치의 현재 상태가 즉시 Front 리포지토리의 develop 브랜치에 반영됩니다. -잘못된 릴리즈는 서비스에 직접적인 영향을 줄 수 있으니, 반드시 팀의 승인을 받고 신중하게 진행해 주십시오. --- From fc63d44e4eb72aaada118bcc81a8687591312b6e Mon Sep 17 00:00:00 2001 From: ChoiseU Date: Sat, 5 Jul 2025 00:23:04 +0900 Subject: [PATCH 02/80] =?UTF-8?q?feat:=20=ED=8F=AC=ED=8A=B8=20=EB=8F=99?= =?UTF-8?q?=EC=A0=81=20=ED=95=A0=EB=8B=B9=20=ED=9B=84=20=EB=AC=B8=EA=B5=AC?= =?UTF-8?q?=EB=A5=BC=20=EC=B6=9C=EB=A0=A5=ED=95=B4=20=EC=9D=BC=EB=A0=89?= =?UTF-8?q?=ED=8A=B8=EB=A1=A0=EC=97=90=EC=84=9C=20=EB=B0=9B=EC=95=84?= =?UTF-8?q?=EC=84=9C=20=EC=82=AC=EC=9A=A9=ED=95=98=EB=8F=84=EB=A1=9D=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/main.py | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/src/main.py b/src/main.py index 2b7529d..36dee5f 100644 --- a/src/main.py +++ b/src/main.py @@ -1,6 +1,21 @@ # src/main.py +import socket +from contextlib import closing from health_check.router import app +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] + if __name__ == "__main__": - # 8080 포트에서 헬스체크 앱 실행 - app.run(host="0.0.0.0", port=33332) \ No newline at end of file + # 1. 비어있는 포트 동적 할당 + free_port = find_free_port() + + # 2. 할당된 포트 번호를 콘솔에 특정 형식으로 출력 + print(f"PYTHON_SERVER_PORT:{free_port}") + + # 3. 할당된 포트로 Flask 서버 실행 + app.run(host="127.0.0.1", port=free_port, debug=False, use_reloader=False) \ No newline at end of file From d817f110a3330258eb34544926aa77c0564b015f Mon Sep 17 00:00:00 2001 From: ChoiseU Date: Sat, 5 Jul 2025 00:23:57 +0900 Subject: [PATCH 03/80] =?UTF-8?q?docs:=20=ED=8F=AC=ED=8A=B8=20=EB=8F=99?= =?UTF-8?q?=EC=A0=81=20=ED=95=A0=EB=8B=B9=EC=9C=BC=EB=A1=9C=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=EC=97=90=20=EB=94=B0=EB=A5=B8=20=EB=A6=AC=EB=93=9C?= =?UTF-8?q?=EB=AF=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/README.md b/README.md index c12b793..10736e3 100644 --- a/README.md +++ b/README.md @@ -37,9 +37,6 @@ # 실행 파일 빌드 pyinstaller src/main.py --name ai --onefile --noconsole - - # 실행 파일 실행 - ./dist/ai ``` --- @@ -78,11 +75,8 @@ GitHub에서 새로운 태그를 발행하면 파이프라인이 자동으로 이 레포지토리에는 간단한 헬스체크 서버가 포함되어 있습니다. 아래 명령어로 실행 파일을 빌드하여 서버 상태를 확인할 수 있습니다. ```bash -# 실행 파일 빌드 -pyinstaller src/main.py --name ai --onefile --noconsole - # 빌드된 파일 실행 (dist 폴더에 생성됨) ./dist/ai -curl http://localhost:33332/health +curl http://localhost:<할당된 포트>/health ``` \ No newline at end of file From 692992e5a8ad3657307d612ae3a50d2e666854eb Mon Sep 17 00:00:00 2001 From: CheckerBoard Date: Fri, 11 Jul 2025 00:17:44 +0900 Subject: [PATCH 04/80] =?UTF-8?q?chore:=20=ED=99=98=EA=B2=BD=EB=B3=80?= =?UTF-8?q?=EC=88=98=20=EB=B0=8F=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 2fa36ba..28c5ad8 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ # 2. 가상환경 파일 *venv* +.env # 3. 빌드 결과물 (Build output) *.spec @@ -11,4 +12,5 @@ dist # 4. 기타 .vscode -.idea \ No newline at end of file +.idea +test.ipynb \ No newline at end of file From 6fc4b919e5e1db441a3632f2b39e8b322f295d04 Mon Sep 17 00:00:00 2001 From: CheckerBoard Date: Fri, 11 Jul 2025 00:41:46 +0900 Subject: [PATCH 05/80] =?UTF-8?q?feat:=20DB=20=EC=97=B0=EA=B2=B0?= 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 insertions(+) create mode 100644 src/core/db_manager.py diff --git a/src/core/db_manager.py b/src/core/db_manager.py new file mode 100644 index 0000000..c06ee1a --- /dev/null +++ b/src/core/db_manager.py @@ -0,0 +1,40 @@ +# 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 8074a5e52ffd9d20a125aeb6b48ea360f5318fc9 Mon Sep 17 00:00:00 2001 From: CheckerBoard Date: Fri, 11 Jul 2025 00:42:07 +0900 Subject: [PATCH 06/80] =?UTF-8?q?chore:=20=5F=5Fpycache=5F=5F=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 28c5ad8..5422313 100644 --- a/.gitignore +++ b/.gitignore @@ -13,4 +13,5 @@ dist # 4. 기타 .vscode .idea -test.ipynb \ No newline at end of file +test.ipynb +__pycache__ \ No newline at end of file From a3700c1657080a082d6341cd421b3a051627b19d Mon Sep 17 00:00:00 2001 From: CheckerBoard Date: Fri, 11 Jul 2025 00:45:33 +0900 Subject: [PATCH 07/80] =?UTF-8?q?feat:=20=EA=B8=B0=EB=B3=B8=20LLM=20?= =?UTF-8?q?=EA=B0=9D=EC=B2=B4=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/core/llm_provider.py | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 src/core/llm_provider.py diff --git a/src/core/llm_provider.py b/src/core/llm_provider.py new file mode 100644 index 0000000..3a5a94c --- /dev/null +++ b/src/core/llm_provider.py @@ -0,0 +1,30 @@ +# 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 From 8113c076ecba9a2020a2eec85391fe5316ac35d4 Mon Sep 17 00:00:00 2001 From: CheckerBoard Date: Fri, 11 Jul 2025 00:51:10 +0900 Subject: [PATCH 08/80] =?UTF-8?q?feat:=20api=20=EC=9D=B8=ED=92=8B=20?= =?UTF-8?q?=EC=95=84=EC=9B=83=ED=92=8B=20=ED=98=95=EC=8B=9D=20=EC=A7=80?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/v1/schemas.py | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 src/api/v1/schemas.py diff --git a/src/api/v1/schemas.py b/src/api/v1/schemas.py new file mode 100644 index 0000000..0d57177 --- /dev/null +++ b/src/api/v1/schemas.py @@ -0,0 +1,9 @@ +# src/api/v1/schemas.py + +from pydantic import BaseModel + +class ChatRequest(BaseModel): + question: str + +class ChatResponse(BaseModel): + answer: str \ No newline at end of file From 7256c89f3adc660a9285dd79294c381372ef1c9a Mon Sep 17 00:00:00 2001 From: CheckerBoard Date: Fri, 11 Jul 2025 00:54:38 +0900 Subject: [PATCH 09/80] =?UTF-8?q?feature:=20sql=20chatbot=20workflow=20?= =?UTF-8?q?=EC=A7=80=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/agents/sql_agent_graph.py | 89 +++++++++++++++++++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 src/agents/sql_agent_graph.py diff --git a/src/agents/sql_agent_graph.py b/src/agents/sql_agent_graph.py new file mode 100644 index 0000000..f753079 --- /dev/null +++ b/src/agents/sql_agent_graph.py @@ -0,0 +1,89 @@ +# app/agents/sql_agent_graph.py + +from typing import List, TypedDict, Optional +from langchain_core.messages import BaseMessage +from langgraph.graph import StateGraph, END +from src.core.db_manager import db_instance +from src.core.llm_provider import llm_instance + +# Agent 상태 정의 +class SqlAgentState(TypedDict): + question: str + chat_history: List[BaseMessage] + db_schema: str + sql_query: str + validation_error: Optional[str] + execution_result: Optional[str] + final_response: str + +# --- 노드 함수 정의 --- +def sql_generator_node(state: SqlAgentState): + print("--- 1. SQL 생성 중 ---") + prompt = f""" + Schema: {state['db_schema']} + History: {state['chat_history']} + Question: {state['question']} + SQL Query: + """ + response = llm_instance.invoke(prompt) + state['sql_query'] = response.content + state['validation_error'] = None + return state + +def sql_validator_node(state: SqlAgentState): + print("--- 2. SQL 검증 중 ---") + query = state['sql_query'].lower() + if "drop" in query or "delete" in query: + state['validation_error'] = "위험한 키워드가 포함되어 있습니다." + else: + state['validation_error'] = None + return state + +def sql_executor_node(state: SqlAgentState): + print("--- 3. SQL 실행 중 ---") + try: + result = db_instance.run(state['sql_query']) + state['execution_result'] = str(result) + except Exception as e: + state['execution_result'] = f"실행 오류: {e}" + return state + +def response_synthesizer_node(state: SqlAgentState): + print("--- 4. 최종 답변 생성 중 ---") + prompt = f""" + Question: {state['question']} + SQL Result: {state['execution_result']} + Final Answer: + """ + response = llm_instance.invoke(prompt) + state['final_response'] = response.content + return state + +# --- 엣지 함수 정의 --- +def should_execute_sql(state: SqlAgentState): + return "regenerate" if state.get("validation_error") else "execute" + +def should_retry_or_respond(state: SqlAgentState): + return "regenerate" if "오류" in (state.get("execution_result") or "") else "synthesize" + +# --- 그래프 구성 --- +def create_sql_agent_graph() -> StateGraph: + graph = StateGraph(SqlAgentState) + 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("sql_generator") + graph.add_edge("sql_generator", "sql_validator") + graph.add_conditional_edges("sql_validator", should_execute_sql, { + "regenerate": "sql_generator", "execute": "sql_executor" + }) + graph.add_conditional_edges("sql_executor", should_retry_or_respond, { + "regenerate": "sql_generator", "synthesize": "response_synthesizer" + }) + graph.add_edge("response_synthesizer", END) + + return graph.compile() + +sql_agent_app = create_sql_agent_graph() \ No newline at end of file From 7dcaceeebb6b66ad282340dbb908742bc355a401 Mon Sep 17 00:00:00 2001 From: CheckerBoard Date: Fri, 11 Jul 2025 17:22:56 +0900 Subject: [PATCH 10/80] =?UTF-8?q?feat:=20sql=20parser=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 | 13 +++++++++++-- src/schemas/sql_schemas.py | 6 ++++++ 2 files changed, 17 insertions(+), 2 deletions(-) create mode 100644 src/schemas/sql_schemas.py diff --git a/src/agents/sql_agent_graph.py b/src/agents/sql_agent_graph.py index f753079..7c96ce3 100644 --- a/src/agents/sql_agent_graph.py +++ b/src/agents/sql_agent_graph.py @@ -3,6 +3,8 @@ 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 src.schemas.sql_schemas import SqlQuery from src.core.db_manager import db_instance from src.core.llm_provider import llm_instance @@ -19,14 +21,21 @@ class SqlAgentState(TypedDict): # --- 노드 함수 정의 --- def sql_generator_node(state: SqlAgentState): print("--- 1. SQL 생성 중 ---") + parser = PydanticOutputParser(pydantic_object=SqlQuery) + prompt = f""" + You are a powerful text-to-SQL model. Your role is to generate a SQL query based on the provided database schema and user question. + + {parser.get_format_instructions()} + Schema: {state['db_schema']} History: {state['chat_history']} Question: {state['question']} - SQL Query: """ + response = llm_instance.invoke(prompt) - state['sql_query'] = response.content + parsed_query = parser.invoke(response) + state['sql_query'] = parsed_query.query state['validation_error'] = None return state diff --git a/src/schemas/sql_schemas.py b/src/schemas/sql_schemas.py new file mode 100644 index 0000000..2201103 --- /dev/null +++ b/src/schemas/sql_schemas.py @@ -0,0 +1,6 @@ +# src/schemas/sql_schemas.py +from pydantic import BaseModel, Field + +class SqlQuery(BaseModel): + """SQL 쿼리를 나타내는 Pydantic 모델""" + query: str = Field(description="생성된 SQL 쿼리") \ No newline at end of file From 54365855e78b25139bacfec56172bb0841c58088 Mon Sep 17 00:00:00 2001 From: CheckerBoard Date: Fri, 11 Jul 2025 17:23:41 +0900 Subject: [PATCH 11/80] feat: chatbot api --- src/api/v1/endpoints/chat.py | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 src/api/v1/endpoints/chat.py diff --git a/src/api/v1/endpoints/chat.py b/src/api/v1/endpoints/chat.py new file mode 100644 index 0000000..a3c3c0a --- /dev/null +++ b/src/api/v1/endpoints/chat.py @@ -0,0 +1,27 @@ +# src/api/v1/endpoints/chat.py + +from fastapi import APIRouter, Depends +from src.api.v1.schemas import ChatRequest, ChatResponse +from src.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) + return ChatResponse(answer=final_answer) \ No newline at end of file From d43d3b12a9fc41319e9315e1f2c6246e1c50b7fc Mon Sep 17 00:00:00 2001 From: CheckerBoard Date: Fri, 11 Jul 2025 17:23:58 +0900 Subject: [PATCH 12/80] =?UTF-8?q?feat:=20chatbot=20=EB=A9=94=EC=9D=B8=20?= =?UTF-8?q?=EC=84=9C=EB=B9=84=EC=8A=A4=20=EB=A1=9C=EC=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/services/chatbot_service.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 src/services/chatbot_service.py diff --git a/src/services/chatbot_service.py b/src/services/chatbot_service.py new file mode 100644 index 0000000..0265f42 --- /dev/null +++ b/src/services/chatbot_service.py @@ -0,0 +1,22 @@ +# src/services/chatbot_service.py + +from src.agents.sql_agent_graph import sql_agent_app +from src.core.db_manager import schema_instance + +class ChatbotService: + def __init__(self): + self.db_schema = schema_instance + + def handle_request(self, user_question: str) -> str: + # 1. 에이전트 그래프에 전달할 초기 상태 구성 + initial_state = { + "question": user_question, + "chat_history": [], + "db_schema": self.db_schema + } + + # 2. 그래프 실행 + final_state = sql_agent_app.invoke(initial_state) + final_response = final_state['final_response'] + + return final_response \ No newline at end of file From 28decdd15df82094536d015c033b8f680c6b9172 Mon Sep 17 00:00:00 2001 From: CheckerBoard Date: Fri, 11 Jul 2025 17:55:40 +0900 Subject: [PATCH 13/80] =?UTF-8?q?chore:=20=EC=9D=98=EC=A1=B4=EC=84=B1=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8(langg?= =?UTF-8?q?raph)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- requirements.txt | 78 +++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 74 insertions(+), 4 deletions(-) diff --git a/requirements.txt b/requirements.txt index e6efb79..de3b0b1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,32 +1,102 @@ +aiohappyeyeballs==2.6.1 +aiohttp==3.12.13 +aiosignal==1.4.0 +altgraph==0.17.4 annotated-types==0.7.0 anyio==4.9.0 +appnope==0.1.4 +asttokens==3.0.0 +attrs==25.3.0 +blinker==1.9.0 certifi==2025.6.15 charset-normalizer==3.4.2 +click==8.2.1 +comm==0.2.2 +dataclasses-json==0.6.7 +debugpy==1.8.14 +decorator==5.2.1 +distro==1.9.0 +executing==2.2.0 +fastapi==0.116.0 +Flask==3.1.1 +frozenlist==1.7.0 h11==0.16.0 httpcore==1.0.9 httpx==0.28.1 +httpx-sse==0.4.1 idna==3.10 +ipykernel==6.29.5 +ipython==9.4.0 +ipython_pygments_lexers==1.1.1 +itsdangerous==2.2.0 +jedi==0.19.2 +Jinja2==3.1.6 +jiter==0.10.0 jsonpatch==1.33 jsonpointer==3.0.0 +jupyter_client==8.6.3 +jupyter_core==5.8.1 langchain==0.3.26 +langchain-community==0.3.27 langchain-core==0.3.67 +langchain-openai==0.3.27 langchain-text-splitters==0.3.8 +langgraph==0.5.1 +langgraph-checkpoint==2.1.0 +langgraph-prebuilt==0.5.2 +langgraph-sdk==0.1.72 langsmith==0.4.4 +macholib==1.16.3 +MarkupSafe==3.0.2 +marshmallow==3.26.1 +matplotlib-inline==0.1.7 +multidict==6.6.3 +mypy_extensions==1.1.0 +mysql-connector-python==9.3.0 +nest-asyncio==1.6.0 +numpy==2.3.1 +openai==1.93.0 orjson==3.10.18 +ormsgpack==1.10.0 packaging==24.2 +parso==0.8.4 +pexpect==4.9.0 +platformdirs==4.3.8 +prompt_toolkit==3.0.51 +propcache==0.3.2 +psutil==7.0.0 +ptyprocess==0.7.0 +pure_eval==0.2.3 pydantic==2.11.7 +pydantic-settings==2.10.1 pydantic_core==2.33.2 +Pygments==2.19.2 +pyinstaller==6.14.2 +pyinstaller-hooks-contrib==2025.5 +python-dateutil==2.9.0.post0 +python-dotenv==1.1.1 PyYAML==6.0.2 +pyzmq==27.0.0 +regex==2024.11.6 requests==2.32.4 requests-toolbelt==1.0.0 +six==1.17.0 sniffio==1.3.1 SQLAlchemy==2.0.41 +stack-data==0.6.3 +starlette==0.46.2 tenacity==9.1.2 +tiktoken==0.9.0 +tornado==6.5.1 +tqdm==4.67.1 +traitlets==5.14.3 +typing-inspect==0.9.0 typing-inspection==0.4.1 typing_extensions==4.14.0 urllib3==2.5.0 +uvicorn==0.35.0 +wcwidth==0.2.13 +Werkzeug==3.1.3 +xxhash==3.5.0 +yarl==1.20.1 zstandard==0.23.0 - -# infra -Flask -pyinstaller From 9c6a00efbdf2ef4443314ad3f173c5d9493b09b5 Mon Sep 17 00:00:00 2001 From: CheckerBoard Date: Sat, 12 Jul 2025 17:01:48 +0900 Subject: [PATCH 14/80] =?UTF-8?q?chore:=20=5F=5Finit=5F=5F=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/core/__init__.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 src/core/__init__.py diff --git a/src/core/__init__.py b/src/core/__init__.py new file mode 100644 index 0000000..e69de29 From cc162a17024b15144b0d2840b71ab28a494f4cba Mon Sep 17 00:00:00 2001 From: CheckerBoard Date: Sat, 12 Jul 2025 17:10:57 +0900 Subject: [PATCH 15/80] =?UTF-8?q?feat:=20fastapi=20=EC=84=9C=EB=B2=84=20?= =?UTF-8?q?=EC=8B=A4=ED=96=89,=20=ED=97=AC=EC=8A=A4=EC=B2=B4=ED=81=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main.py | 28 +++++++++++++++++++++++++--- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/src/main.py b/src/main.py index 36dee5f..3615767 100644 --- a/src/main.py +++ b/src/main.py @@ -1,7 +1,10 @@ # src/main.py + import socket from contextlib import closing -from health_check.router import app +import uvicorn +from fastapi import FastAPI +from src.api.v1.endpoints import chat def find_free_port(): """사용 가능한 비어있는 포트를 찾는 함수""" @@ -10,6 +13,25 @@ def find_free_port(): s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) return s.getsockname()[1] +# FastAPI 앱 인스턴스 생성 +app = FastAPI( + title="Qgenie - Agentic SQL Chatbot", + description="LangGraph로 구현된 사전 스키마를 지원하는 SQL 챗봇", + version="1.0.0" +) + +# '/api/v1' 경로에 chat 라우터 포함 +app.include_router( + chat.router, + prefix="/api/v1", + tags=["Chatbot"] +) + +@app.get("/") +def health_check(): + """헬스체크 엔드포인트, 서버 상태가 정상이면 'ok' 반환합니다.""" + return {"status": "ok", "message": "Welcome to the AskQL Chatbot API!"} + if __name__ == "__main__": # 1. 비어있는 포트 동적 할당 free_port = find_free_port() @@ -17,5 +39,5 @@ def find_free_port(): # 2. 할당된 포트 번호를 콘솔에 특정 형식으로 출력 print(f"PYTHON_SERVER_PORT:{free_port}") - # 3. 할당된 포트로 Flask 서버 실행 - app.run(host="127.0.0.1", port=free_port, debug=False, use_reloader=False) \ No newline at end of file + # 3. 할당된 포트로 FastAPI 서버 실행 + uvicorn.run(app, host="127.0.0.1", port=free_port, reload=False) \ No newline at end of file From abd318e0983311ca530c0c0a61141d4470dad0bb Mon Sep 17 00:00:00 2001 From: CheckerBoard Date: Sun, 13 Jul 2025 15:32:15 +0900 Subject: [PATCH 16/80] =?UTF-8?q?chore:=20mysql=20=EB=93=9C=EB=9D=BC?= =?UTF-8?q?=EC=9D=B4=EB=B2=84=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index de3b0b1..892ea35 100644 --- a/requirements.txt +++ b/requirements.txt @@ -52,7 +52,6 @@ marshmallow==3.26.1 matplotlib-inline==0.1.7 multidict==6.6.3 mypy_extensions==1.1.0 -mysql-connector-python==9.3.0 nest-asyncio==1.6.0 numpy==2.3.1 openai==1.93.0 @@ -73,6 +72,7 @@ pydantic_core==2.33.2 Pygments==2.19.2 pyinstaller==6.14.2 pyinstaller-hooks-contrib==2025.5 +PyMySQL==1.1.1 python-dateutil==2.9.0.post0 python-dotenv==1.1.1 PyYAML==6.0.2 From 17045c3074014f421ffaadb8885afafe9738e042 Mon Sep 17 00:00:00 2001 From: CheckerBoard Date: Sun, 13 Jul 2025 15:33:00 +0900 Subject: [PATCH 17/80] =?UTF-8?q?chore:=20import=20=EA=B2=BD=EB=A1=9C=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD(scr=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_graph.py | 6 +++--- src/api/v1/endpoints/chat.py | 4 ++-- src/main.py | 2 +- src/services/chatbot_service.py | 4 ++-- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/agents/sql_agent_graph.py b/src/agents/sql_agent_graph.py index 7c96ce3..fb4316b 100644 --- a/src/agents/sql_agent_graph.py +++ b/src/agents/sql_agent_graph.py @@ -4,9 +4,9 @@ from langchain_core.messages import BaseMessage from langgraph.graph import StateGraph, END from langchain.output_parsers.pydantic import PydanticOutputParser -from src.schemas.sql_schemas import SqlQuery -from src.core.db_manager import db_instance -from src.core.llm_provider import llm_instance +from schemas.sql_schemas import SqlQuery +from core.db_manager import db_instance +from core.llm_provider import llm_instance # Agent 상태 정의 class SqlAgentState(TypedDict): diff --git a/src/api/v1/endpoints/chat.py b/src/api/v1/endpoints/chat.py index a3c3c0a..e3ca8dc 100644 --- a/src/api/v1/endpoints/chat.py +++ b/src/api/v1/endpoints/chat.py @@ -1,8 +1,8 @@ # src/api/v1/endpoints/chat.py from fastapi import APIRouter, Depends -from src.api.v1.schemas import ChatRequest, ChatResponse -from src.services.chatbot_service import ChatbotService +from api.v1.schemas import ChatRequest, ChatResponse +from services.chatbot_service import ChatbotService router = APIRouter() diff --git a/src/main.py b/src/main.py index 3615767..d51f801 100644 --- a/src/main.py +++ b/src/main.py @@ -4,7 +4,7 @@ from contextlib import closing import uvicorn from fastapi import FastAPI -from src.api.v1.endpoints import chat +from api.v1.endpoints import chat def find_free_port(): """사용 가능한 비어있는 포트를 찾는 함수""" diff --git a/src/services/chatbot_service.py b/src/services/chatbot_service.py index 0265f42..3a40972 100644 --- a/src/services/chatbot_service.py +++ b/src/services/chatbot_service.py @@ -1,7 +1,7 @@ # src/services/chatbot_service.py -from src.agents.sql_agent_graph import sql_agent_app -from src.core.db_manager import schema_instance +from agents.sql_agent_graph import sql_agent_app +from core.db_manager import schema_instance class ChatbotService: def __init__(self): From 4db3a7f583f89ab4d871432e96f4f46909b36925 Mon Sep 17 00:00:00 2001 From: ChoiseU <105736874+ChoiSeungWoo98@users.noreply.github.com> Date: Fri, 18 Jul 2025 14:25:21 +0900 Subject: [PATCH 18/80] =?UTF-8?q?feat:=20pr=20=EC=95=8C=EB=A6=BC=20?= =?UTF-8?q?=EB=B4=87=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/pr_bot.yml | 86 ++++++++++++++++++++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 .github/workflows/pr_bot.yml diff --git a/.github/workflows/pr_bot.yml b/.github/workflows/pr_bot.yml new file mode 100644 index 0000000..22daee4 --- /dev/null +++ b/.github/workflows/pr_bot.yml @@ -0,0 +1,86 @@ +# .github/workflows/pr_bot.yml +name: Pull Request Bot + +on: + # Pull Request 관련 이벤트 발생 시 + pull_request: + types: [opened, closed, reopened, synchronize] + issue_comment: + types: [created] + +jobs: + notify: + runs-on: ubuntu-latest + steps: + # ------------------------- + # 생성/동기화 알림 + # ------------------------- + - name: Send PR Created Notification + if: github.event_name == 'pull_request' && (github.event.action == 'opened' || github.event.action == 'synchronize') + run: | + curl -X POST -H "Content-Type: application/json" \ + -d '{ + "username": "GitHub PR 봇", + "embeds": [{ + "title": "Pull Request #${{ github.event.pull_request.number }}: ${{ github.event.pull_request.title }}", + "description": "**${{ github.actor }}**님이 Pull Request를 생성하거나 업데이트했습니다.", + "url": "${{ github.event.pull_request.html_url }}", + "color": 2243312 + }] + }' \ + ${{ secrets.DISCORD_WEBHOOK_URL }} + + # ------------------------- + # 댓글 알림 + # ------------------------- + - name: Send PR Comment Notification + if: github.event_name == 'issue_comment' && github.event.issue.pull_request + run: | + COMMENT_BODY=$(echo "${{ github.event.comment.body }}" | sed 's/"/\\"/g' | sed ':a;N;$!ba;s/\n/\\n/g') + curl -X POST -H "Content-Type: application/json" \ + -d "{ + \"username\": \"GitHub 댓글 봇\", + \"embeds\": [{ + \"title\": \"New Comment on PR #${{ github.event.issue.number }}\", + \"description\": \"**${{ github.actor }}**님의 새 댓글: \\n${COMMENT_BODY}\", + \"url\": \"${{ github.event.comment.html_url }}\", + \"color\": 15105570 + }] + }" \ + ${{ secrets.DISCORD_WEBHOOK_URL }} + + # ------------------------- + # 머지(Merge) 알림 + # ------------------------- + - name: Send PR Merged Notification + if: github.event.action == 'closed' && github.event.pull_request.merged == true + run: | + curl -X POST -H "Content-Type: application/json" \ + -d '{ + "username": "GitHub Merge 봇", + "embeds": [{ + "title": "Pull Request #${{ github.event.pull_request.number }} Merged!", + "description": "**${{ github.actor }}**님이 **${{ github.event.pull_request.title }}** PR을 머지했습니다.", + "url": "${{ github.event.pull_request.html_url }}", + "color": 5167473 + }] + }' \ + ${{ secrets.DISCORD_WEBHOOK_URL }} + + # ------------------------- + # 닫힘(Close) 알림 + # ------------------------- + - name: Send PR Closed Notification + if: github.event.action == 'closed' && github.event.pull_request.merged == false + run: | + curl -X POST -H "Content-Type: application/json" \ + -d '{ + "username": "GitHub PR 봇", + "embeds": [{ + "title": "Pull Request #${{ github.event.pull_request.number }} Closed", + "description": "**${{ github.actor }}**님이 **${{ github.event.pull_request.title }}** PR을 닫았습니다.", + "url": "${{ github.event.pull_request.html_url }}", + "color": 15219495 + }] + }' \ + ${{ secrets.DISCORD_WEBHOOK_URL }} From 31cfdec7589a6eabf4232a0ebe02c8ebb07ad7cc Mon Sep 17 00:00:00 2001 From: ChoiseU <105736874+ChoiSeungWoo98@users.noreply.github.com> Date: Fri, 18 Jul 2025 14:31:20 +0900 Subject: [PATCH 19/80] =?UTF-8?q?feat:=20=EB=94=94=EC=8A=A4=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EB=B0=B0=ED=8F=AC=20=EC=95=8C=EB=A6=BC=20=EB=B4=87?= =?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 --- .github/workflows/deploy_executables.yml | 58 ++++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/.github/workflows/deploy_executables.yml b/.github/workflows/deploy_executables.yml index a9339ce..ad58b91 100644 --- a/.github/workflows/deploy_executables.yml +++ b/.github/workflows/deploy_executables.yml @@ -6,8 +6,26 @@ on: types: [published] jobs: + # ================================== + # 1. 파이프라인 시작 알림 + # ================================== + start: + runs-on: ubuntu-latest + steps: + - name: Send Pipeline Start Notification + run: | + curl -X POST -H "Content-Type: application/json" \ + -d '{ + "username": "AI 배포 봇", + "embeds": [{ + "description": "**${{ github.ref_name }}** AI 모델 실행 파일 배포를 시작합니다.", + "color": 2243312 + }] + }' \ + ${{ secrets.DISCORD_WEBHOOK_URL }} # 1단계: 각 OS에서 실행 파일을 빌드하는 잡 build: + needs: start strategy: matrix: os: [macos-latest, windows-latest] @@ -59,6 +77,7 @@ jobs: with: name: executable-${{ runner.os }} path: dist/${{ env.EXE_NAME }} + # 2단계: 빌드된 실행 파일들을 Front 레포지토리에 배포하는 잡 deploy: # build 잡이 성공해야 실행됨 @@ -101,3 +120,42 @@ jobs: git commit -m "feat: AI 실행 파일 업데이트 (${{ github.ref_name }})" git push fi + + # ================================== + # 파이프라인 최종 결과 알림 + # ================================== + finish: + needs: [start, build, deploy] + runs-on: ubuntu-latest + if: always() + steps: + - name: Send Success Notification + if: success() + run: | + curl -X POST -H "Content-Type: application/json" \ + -d '{ + "username": "AI 배포 봇", + "embeds": [{ + "title": "New AI Release: ${{ github.ref_name }}", + "url": "${{ github.event.release.html_url }}", + "description": "**${{ github.ref_name }}** AI 실행 파일 배포가 성공적으로 완료되었습니다!", + "color": 3066993 + }] + }' \ + ${{ secrets.DISCORD_WEBHOOK_URL }} + + - name: Send Failure Notification + if: failure() + run: | + curl -X POST -H "Content-Type: application/json" \ + -d '{ + "username": "AI 배포 봇", + "embeds": [{ + "title": "AI 실행 파일 배포 실패", + "url": "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}", + "description": "**${{ github.ref_name }}** AI 실행 파일 배포 중 오류가 발생했습니다.", + "color": 15158332 + }] + }' \ + ${{ secrets.DISCORD_WEBHOOK_URL }} + From aa8b0d7a3dca4491d33ca4572f0871451eafa391 Mon Sep 17 00:00:00 2001 From: ChoiseU Date: Fri, 18 Jul 2025 20:24:59 +0900 Subject: [PATCH 20/80] =?UTF-8?q?style:=20=EB=85=B8=EC=B6=9C=20=EB=A9=98?= =?UTF-8?q?=ED=8A=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/deploy_executables.yml | 35 +++++++++++++++++------- 1 file changed, 25 insertions(+), 10 deletions(-) diff --git a/.github/workflows/deploy_executables.yml b/.github/workflows/deploy_executables.yml index ad58b91..810f485 100644 --- a/.github/workflows/deploy_executables.yml +++ b/.github/workflows/deploy_executables.yml @@ -18,7 +18,7 @@ jobs: -d '{ "username": "AI 배포 봇", "embeds": [{ - "description": "**${{ github.ref_name }}** AI 모델 실행 파일 배포를 시작합니다.", + "description": "**${{ github.ref_name }}** AI 배포를 시작합니다.", "color": 2243312 }] }' \ @@ -125,12 +125,13 @@ jobs: # 파이프라인 최종 결과 알림 # ================================== finish: - needs: [start, build, deploy] + needs: deploy runs-on: ubuntu-latest if: always() + steps: - name: Send Success Notification - if: success() + if: needs.deploy.result == 'success' run: | curl -X POST -H "Content-Type: application/json" \ -d '{ @@ -138,24 +139,38 @@ jobs: "embeds": [{ "title": "New AI Release: ${{ github.ref_name }}", "url": "${{ github.event.release.html_url }}", - "description": "**${{ github.ref_name }}** AI 실행 파일 배포가 성공적으로 완료되었습니다!", - "color": 3066993 + "description": "**${{ github.ref_name }}** AI 배포가 성공적으로 완료되었습니다!", + "color": 5167473 }] }' \ ${{ secrets.DISCORD_WEBHOOK_URL }} - name: Send Failure Notification - if: failure() + if: contains(needs.*.result, 'failure') run: | curl -X POST -H "Content-Type: application/json" \ -d '{ "username": "AI 배포 봇", "embeds": [{ - "title": "AI 실행 파일 배포 실패", + "title": "AI 배포 실패", "url": "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}", - "description": "**${{ github.ref_name }}** AI 실행 파일 배포 중 오류가 발생했습니다.", - "color": 15158332 + "description": "**${{ github.ref_name }}** AI 배포 중 오류가 발생했습니다.", + "color": 15219495 }] }' \ ${{ secrets.DISCORD_WEBHOOK_URL }} - + + - name: Send Skipped or Cancelled Notification + if: contains(needs.*.result, 'cancelled') || contains(needs.*.result, 'skipped') + run: | + curl -X POST -H "Content-Type: application/json" \ + -d '{ + "username": "AI 배포 봇", + "embeds": [{ + "title": "AI 배포 미완료", + "url": "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}", + "description": "**${{ github.ref_name }}** AI 배포가 완료되지 않았습니다. (상태: 취소 또는 건너뜀)\n이전 단계에서 문제가 발생했을 수 있습니다.", + "color": 16577629 + }] + }' \ + ${{ secrets.DISCORD_WEBHOOK_URL }} From 1935f557d95f4e2c5da7f86775096fe29e578866 Mon Sep 17 00:00:00 2001 From: ChoiseU Date: Fri, 18 Jul 2025 20:33:56 +0900 Subject: [PATCH 21/80] =?UTF-8?q?docs:=20clone=20=EC=A3=BC=EC=86=8C=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 --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 10736e3..980b7ca 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ 1. **저장소 복제** ```bash - git clone https://github.com/AskQL/AI.git + git clone https://github.com/Queryus/QGenie_api.git ``` 2. **가상 환경 생성 및 활성화** From 8b21f63698a76a567431b40f13d2251bde1e6cc4 Mon Sep 17 00:00:00 2001 From: ChoiseU Date: Sat, 19 Jul 2025 14:23:24 +0900 Subject: [PATCH 22/80] =?UTF-8?q?chore:=20pyinstaller=20->=20nuitka=20?= =?UTF-8?q?=EB=A1=9C=20=EB=B9=8C=EB=93=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index 5422313..08a841b 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,8 @@ *.spec build dist +main.build +main.dist # 4. 기타 .vscode From cb5e69334cb212fd4a9160f8e99997562d5855cf Mon Sep 17 00:00:00 2001 From: ChoiseU Date: Sat, 19 Jul 2025 14:24:43 +0900 Subject: [PATCH 23/80] =?UTF-8?q?chore:=20pyinstaller=20->=20nuitka=20?= =?UTF-8?q?=EB=A1=9C=20=EB=B9=8C=EB=93=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- requirements.txt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index 892ea35..1c28978 100644 --- a/requirements.txt +++ b/requirements.txt @@ -70,8 +70,6 @@ pydantic==2.11.7 pydantic-settings==2.10.1 pydantic_core==2.33.2 Pygments==2.19.2 -pyinstaller==6.14.2 -pyinstaller-hooks-contrib==2025.5 PyMySQL==1.1.1 python-dateutil==2.9.0.post0 python-dotenv==1.1.1 @@ -100,3 +98,4 @@ Werkzeug==3.1.3 xxhash==3.5.0 yarl==1.20.1 zstandard==0.23.0 +nuitka From 39cedec7dedf3232ccd231965aed4586a8a27413 Mon Sep 17 00:00:00 2001 From: ChoiseU Date: Sat, 19 Jul 2025 14:25:44 +0900 Subject: [PATCH 24/80] =?UTF-8?q?style:=20=EB=B9=8C=EB=93=9C=20=EB=B0=A9?= =?UTF-8?q?=EB=B2=95=20=EC=9E=AC=20=EC=9E=91=EC=84=B1=20=EB=B0=8F=20?= =?UTF-8?q?=ED=97=AC=EC=8A=A4=EC=B2=B4=ED=81=AC=20=EB=AC=B8=EA=B5=AC=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 --- README.md | 18 +++++++++++++----- src/main.py | 2 +- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 10736e3..50fd75d 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ 1. **저장소 복제** ```bash - git clone https://github.com/AskQL/AI.git + git clone https://github.com/Queryus/QGenie_ai.git ``` 2. **가상 환경 생성 및 활성화** @@ -20,8 +20,11 @@ # 가상 환경 생성 (최초 한 번) python3 -m venv .venv - # 가상 환경 활성화 + # 가상 환경 활성화 (macOS/Linux) source .venv/bin/activate + + # 가상 환경 활성화 (Windows) + .venv\Scripts\activate ``` 3. **라이브러리 설치** @@ -33,10 +36,14 @@ 4. **실행파일 생성 후 확인** ```bash # 이전 빌드 결과물 삭제 - rm -rf build dist + rm -rf src/main.build src/main.dist src/main.onefile-build dist + rm -rf main.build main.dist main.onefile-build dist + + # 실행 파일 빌드-Nuitka (macOS/Linux) + nuitka --follow-imports --standalone --output-filename=qgenie-ai src/main.py - # 실행 파일 빌드 - pyinstaller src/main.py --name ai --onefile --noconsole + # 실행 파일 빌드-Nuitka (Windows) + nuitka --follow-imports --standalone --output-filename=qgenie-ai.exe src/main.py ``` --- @@ -78,5 +85,6 @@ GitHub에서 새로운 태그를 발행하면 파이프라인이 자동으로 # 빌드된 파일 실행 (dist 폴더에 생성됨) ./dist/ai +# 다른 터미널에서 헬스체크 요청 curl http://localhost:<할당된 포트>/health ``` \ No newline at end of file diff --git a/src/main.py b/src/main.py index d51f801..98c0984 100644 --- a/src/main.py +++ b/src/main.py @@ -30,7 +30,7 @@ def find_free_port(): @app.get("/") def health_check(): """헬스체크 엔드포인트, 서버 상태가 정상이면 'ok' 반환합니다.""" - return {"status": "ok", "message": "Welcome to the AskQL Chatbot API!"} + return {"status": "ok", "message": "Welcome to the QGenie Chatbot AI!"} if __name__ == "__main__": # 1. 비어있는 포트 동적 할당 From cf9e6c7102dde9ded8c9d27b0c9815cc8cd694df Mon Sep 17 00:00:00 2001 From: ChoiseU Date: Sat, 19 Jul 2025 14:26:07 +0900 Subject: [PATCH 25/80] =?UTF-8?q?refactor:=20=EB=B9=8C=EB=93=9C=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 --- .github/workflows/deploy_executables.yml | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/.github/workflows/deploy_executables.yml b/.github/workflows/deploy_executables.yml index 810f485..b4a9cf1 100644 --- a/.github/workflows/deploy_executables.yml +++ b/.github/workflows/deploy_executables.yml @@ -4,6 +4,7 @@ name: Build and Deploy Executables # 워크플로우의 전체 이름 on: release: types: [published] + workflow_dispatch: jobs: # ================================== @@ -62,21 +63,22 @@ jobs: shell: bash run: | if [ "${{ runner.os }}" == "macOS" ]; then - echo "EXE_NAME=askql-ai" >> $GITHUB_ENV + echo "EXE_NAME=qgenie-ai" >> $GITHUB_ENV elif [ "${{ runner.os }}" == "Windows" ]; then - echo "EXE_NAME=askql-ai.exe" >> $GITHUB_ENV + echo "EXE_NAME=qgenie-ai.exe" >> $GITHUB_ENV fi - # 6. PyInstaller를 사용해 파이썬 코드를 실행 파일로 만듭니다. - - name: Build executable with PyInstaller - run: pyinstaller src/main.py --name ${{ env.EXE_NAME }} --onefile --noconsole + # 6. Nuitka를 사용해 파이썬 코드를 실행 파일로 만듭니다. + - name: Build executable with Nuitka + shell: bash + run: python -m nuitka --follow-imports --standalone --output-dir=dist --output-filename=${{ env.EXE_NAME }} src/main.py # 7. 빌드된 실행 파일을 다음 단계(deploy)에서 사용할 수 있도록 아티팩트로 업로드합니다. - name: Upload artifact uses: actions/upload-artifact@v4 with: name: executable-${{ runner.os }} - path: dist/${{ env.EXE_NAME }} + path: dist/${{ env.EXE_NAME }}.dist # 2단계: 빌드된 실행 파일들을 Front 레포지토리에 배포하는 잡 deploy: @@ -85,10 +87,10 @@ jobs: runs-on: ubuntu-latest steps: # 1. 배포 대상인 Front 리포지토리의 코드를 가져옵니다. - - name: Checkout Front Repository + - name: Checkout App Repository uses: actions/checkout@v4 with: - repository: AskQL/Front + repository: Queryus/QGenie_app token: ${{ secrets.PAT_FOR_FRONT_REPO }} # 배포할 브랜치를 develop으로 변경 ref: develop @@ -97,15 +99,14 @@ jobs: - name: Download all artifacts uses: actions/download-artifact@v4 with: - # artifacts 폴더에 모든 아티팩트를 다운로드 path: artifacts # 3. 다운로드한 실행 파일들을 정해진 폴더(resources/mac, resources/win)로 이동시킵니다. - name: Organize files run: | mkdir -p resources/mac resources/win - mv artifacts/executable-macOS/askql-ai resources/mac/ - mv artifacts/executable-Windows/askql-ai.exe resources/win/ + mv artifacts/executable-macOS resources/mac/qgenie-ai + mv artifacts/executable-Windows resources/win/qgenie-ai.exe # 4. 변경된 파일들을 Front 리포지토리에 커밋하고 푸시합니다. - name: Commit and push changes From e56421157b51009563c3336ca03dd0ebde372da3 Mon Sep 17 00:00:00 2001 From: ChoiseU Date: Wed, 23 Jul 2025 23:59:54 +0900 Subject: [PATCH 26/80] =?UTF-8?q?refactor:=20Nuitka=EB=A5=BC=20=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=ED=95=98=EB=8D=98=20=EB=B9=8C=EB=93=9C=20pyinstaller?= =?UTF-8?q?=EB=A1=9C=20=EC=9B=90=EB=B3=B5=20-=20=EB=B9=8C=EB=93=9C=20?= =?UTF-8?q?=EC=8B=9C=EA=B0=84=20=EC=A6=9D=EA=B0=80=EB=A1=9C=20=EC=9D=B8?= =?UTF-8?q?=ED=95=9C=20=EC=9B=90=EB=B3=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/deploy_executables.yml | 9 ++++----- .gitignore | 2 -- README.md | 10 +++------- requirements.txt | 3 ++- 4 files changed, 9 insertions(+), 15 deletions(-) diff --git a/.github/workflows/deploy_executables.yml b/.github/workflows/deploy_executables.yml index b4a9cf1..25ab236 100644 --- a/.github/workflows/deploy_executables.yml +++ b/.github/workflows/deploy_executables.yml @@ -68,17 +68,16 @@ jobs: echo "EXE_NAME=qgenie-ai.exe" >> $GITHUB_ENV fi - # 6. Nuitka를 사용해 파이썬 코드를 실행 파일로 만듭니다. - - name: Build executable with Nuitka - shell: bash - run: python -m nuitka --follow-imports --standalone --output-dir=dist --output-filename=${{ env.EXE_NAME }} src/main.py + # 6. PyInstaller를 사용해 파이썬 코드를 실행 파일로 만듭니다. + - name: Build executable with PyInstaller + run: pyinstaller --clean --onefile --name ${{ env.EXE_NAME }} src/main.py # 7. 빌드된 실행 파일을 다음 단계(deploy)에서 사용할 수 있도록 아티팩트로 업로드합니다. - name: Upload artifact uses: actions/upload-artifact@v4 with: name: executable-${{ runner.os }} - path: dist/${{ env.EXE_NAME }}.dist + path: dist/${{ env.EXE_NAME }} # 2단계: 빌드된 실행 파일들을 Front 레포지토리에 배포하는 잡 deploy: diff --git a/.gitignore b/.gitignore index 08a841b..5422313 100644 --- a/.gitignore +++ b/.gitignore @@ -9,8 +9,6 @@ *.spec build dist -main.build -main.dist # 4. 기타 .vscode diff --git a/README.md b/README.md index 50fd75d..e6c9c90 100644 --- a/README.md +++ b/README.md @@ -36,14 +36,10 @@ 4. **실행파일 생성 후 확인** ```bash # 이전 빌드 결과물 삭제 - rm -rf src/main.build src/main.dist src/main.onefile-build dist - rm -rf main.build main.dist main.onefile-build dist + rm -rf build dist - # 실행 파일 빌드-Nuitka (macOS/Linux) - nuitka --follow-imports --standalone --output-filename=qgenie-ai src/main.py - - # 실행 파일 빌드-Nuitka (Windows) - nuitka --follow-imports --standalone --output-filename=qgenie-ai.exe src/main.py + # 실행 파일 빌드 + pyinstaller --clean --onefile --name ai src/main.py ``` --- diff --git a/requirements.txt b/requirements.txt index 1c28978..892ea35 100644 --- a/requirements.txt +++ b/requirements.txt @@ -70,6 +70,8 @@ pydantic==2.11.7 pydantic-settings==2.10.1 pydantic_core==2.33.2 Pygments==2.19.2 +pyinstaller==6.14.2 +pyinstaller-hooks-contrib==2025.5 PyMySQL==1.1.1 python-dateutil==2.9.0.post0 python-dotenv==1.1.1 @@ -98,4 +100,3 @@ Werkzeug==3.1.3 xxhash==3.5.0 yarl==1.20.1 zstandard==0.23.0 -nuitka From a434020efc9cd787aada02ffa6ea51156b1de047 Mon Sep 17 00:00:00 2001 From: ChoiseU Date: Fri, 25 Jul 2025 00:58:36 +0900 Subject: [PATCH 27/80] =?UTF-8?q?refactor:=20qgenie-ai=20=ED=8F=B4?= =?UTF-8?q?=EB=8D=94=20=EC=83=9D=EA=B8=B0=EB=8D=98=20=EB=AC=B8=EC=A0=9C=20?= =?UTF-8?q?=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/deploy_executables.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/deploy_executables.yml b/.github/workflows/deploy_executables.yml index 25ab236..ddc5d93 100644 --- a/.github/workflows/deploy_executables.yml +++ b/.github/workflows/deploy_executables.yml @@ -104,8 +104,8 @@ jobs: - name: Organize files run: | mkdir -p resources/mac resources/win - mv artifacts/executable-macOS resources/mac/qgenie-ai - mv artifacts/executable-Windows resources/win/qgenie-ai.exe + mv artifacts/executable-macOS/qgenie-ai resources/mac/ + mv artifacts/executable-Windows/qgenie-ai.exe resources/win/ # 4. 변경된 파일들을 Front 리포지토리에 커밋하고 푸시합니다. - name: Commit and push changes From 67b4ee5baadf0b9a10054664df659d6b48a0dd57 Mon Sep 17 00:00:00 2001 From: ChoiseU Date: Fri, 25 Jul 2025 01:12:20 +0900 Subject: [PATCH 28/80] =?UTF-8?q?style:=20app=20=EC=AA=BD=20=EC=BB=A4?= =?UTF-8?q?=EB=B0=8B=20=EB=A9=94=EC=8B=9C=EC=A7=80=20=EC=98=81=EC=96=B4?= =?UTF-8?q?=EB=A1=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/deploy_executables.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/deploy_executables.yml b/.github/workflows/deploy_executables.yml index ddc5d93..05b000a 100644 --- a/.github/workflows/deploy_executables.yml +++ b/.github/workflows/deploy_executables.yml @@ -78,6 +78,7 @@ jobs: with: name: executable-${{ runner.os }} path: dist/${{ env.EXE_NAME }} + retention-days: 1 # 2단계: 빌드된 실행 파일들을 Front 레포지토리에 배포하는 잡 deploy: @@ -117,7 +118,7 @@ jobs: if git diff-index --quiet HEAD; then echo "No changes to commit." else - git commit -m "feat: AI 실행 파일 업데이트 (${{ github.ref_name }})" + git commit -m "feat: Update AI executable (${{ github.ref_name }})" git push fi From 1c53449187997a24deb908462508744b42c7311e Mon Sep 17 00:00:00 2001 From: ChoiseU Date: Fri, 25 Jul 2025 12:41:37 +0900 Subject: [PATCH 29/80] feat: Skip deployment jobs on manual trigger --- .github/workflows/deploy_executables.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/deploy_executables.yml b/.github/workflows/deploy_executables.yml index 05b000a..2317b2e 100644 --- a/.github/workflows/deploy_executables.yml +++ b/.github/workflows/deploy_executables.yml @@ -11,6 +11,7 @@ jobs: # 1. 파이프라인 시작 알림 # ================================== start: + if: github.event_name == 'release' runs-on: ubuntu-latest steps: - name: Send Pipeline Start Notification @@ -26,7 +27,6 @@ jobs: ${{ secrets.DISCORD_WEBHOOK_URL }} # 1단계: 각 OS에서 실행 파일을 빌드하는 잡 build: - needs: start strategy: matrix: os: [macos-latest, windows-latest] @@ -84,6 +84,7 @@ jobs: deploy: # build 잡이 성공해야 실행됨 needs: build + if: github.event_name == 'release' runs-on: ubuntu-latest steps: # 1. 배포 대상인 Front 리포지토리의 코드를 가져옵니다. @@ -128,7 +129,7 @@ jobs: finish: needs: deploy runs-on: ubuntu-latest - if: always() + if: always() && github.event_name == 'release' steps: - name: Send Success Notification From d63a0b523973104a8c9b0415507edf251d85b6e6 Mon Sep 17 00:00:00 2001 From: CheckerBoard Date: Sat, 26 Jul 2025 18:31:27 +0900 Subject: [PATCH 30/80] =?UTF-8?q?chore:=20=EB=9E=AD=EC=B2=B4=EC=9D=B8=20?= =?UTF-8?q?=EC=9B=8C=ED=81=AC=ED=94=8C=EB=A1=9C=EC=9A=B0=20=EC=9D=B4?= =?UTF-8?q?=EB=AF=B8=EC=A7=80=20=EA=B2=B0=EA=B3=BC=EB=AC=BC=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 5422313..6d83b6d 100644 --- a/.gitignore +++ b/.gitignore @@ -14,4 +14,5 @@ dist .vscode .idea test.ipynb -__pycache__ \ No newline at end of file +__pycache__ +workflow_graph.png From e6842fcbaa589def19fc9a135cfa5504f7372410 Mon Sep 17 00:00:00 2001 From: CheckerBoard Date: Sat, 26 Jul 2025 19:13:13 +0900 Subject: [PATCH 31/80] =?UTF-8?q?feat:=20=ED=94=BC=EB=93=9C=EB=B0=B1=20?= =?UTF-8?q?=EB=A3=A8=ED=94=84=20=EC=B5=9C=EB=8C=80=203=ED=9A=8C=20?= =?UTF-8?q?=EC=A0=9C=ED=95=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/agents/sql_agent_graph.py | 100 ++++++++++++++++++++++++++++---- src/services/chatbot_service.py | 6 +- 2 files changed, 92 insertions(+), 14 deletions(-) diff --git a/src/agents/sql_agent_graph.py b/src/agents/sql_agent_graph.py index fb4316b..72b0e3c 100644 --- a/src/agents/sql_agent_graph.py +++ b/src/agents/sql_agent_graph.py @@ -1,4 +1,4 @@ -# app/agents/sql_agent_graph.py +# src/agents/sql_agent_graph.py from typing import List, TypedDict, Optional from langchain_core.messages import BaseMessage @@ -8,6 +8,8 @@ from core.db_manager import db_instance from core.llm_provider import llm_instance +MAX_ERROR_COUNT = 3 + # Agent 상태 정의 class SqlAgentState(TypedDict): question: str @@ -15,7 +17,9 @@ class SqlAgentState(TypedDict): db_schema: str sql_query: str validation_error: Optional[str] + validation_error_count: int execution_result: Optional[str] + execution_error_count: int final_response: str # --- 노드 함수 정의 --- @@ -23,6 +27,23 @@ 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 = f""" You are a powerful text-to-SQL model. Your role is to generate a SQL query based on the provided database schema and user question. @@ -30,22 +51,35 @@ def sql_generator_node(state: SqlAgentState): Schema: {state['db_schema']} History: {state['chat_history']} + + {error_feedback} + Question: {state['question']} """ - + response = llm_instance.invoke(prompt) parsed_query = parser.invoke(response) 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 = state['sql_query'].lower() - if "drop" in query or "delete" in query: - state['validation_error'] = "위험한 키워드가 포함되어 있습니다." + 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): @@ -53,16 +87,32 @@ def sql_executor_node(state: SqlAgentState): 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. 최종 답변 생성 중 ---") + + if state.get('validation_error_count', 0) >= MAX_ERROR_COUNT: + message = f"SQL 검증에 {MAX_ERROR_COUNT}회 이상 실패했습니다. 마지막 오류: {state.get('validation_error')}" + elif state.get('execution_error_count', 0) >= MAX_ERROR_COUNT: + message = f"SQL 실행에 {MAX_ERROR_COUNT}회 이상 실패했습니다. 마지막 오류: {state.get('execution_result')}" + else: + message = f"SQL Result: {state['execution_result']}" + prompt = f""" Question: {state['question']} - SQL Result: {state['execution_result']} - Final Answer: + SQL: {state['sql_query']} + {message} + + Based on the information above, provide a final answer to the user in Korean. + If there was an error, explain the problem to the user in a friendly way. + 사용자 질문과 쿼리가 어떤 관계가 있는지 같이 설명해 """ response = llm_instance.invoke(prompt) state['final_response'] = response.content @@ -70,10 +120,26 @@ def response_synthesizer_node(state: SqlAgentState): # --- 엣지 함수 정의 --- def should_execute_sql(state: SqlAgentState): - return "regenerate" if state.get("validation_error") else "execute" + """SQL 검증 후, 실행할지/재생성할지/포기할지 결정합니다.""" + 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): - return "regenerate" if "오류" in (state.get("execution_result") or "") else "synthesize" + """SQL 실행 후, 성공/재시도/포기 여부를 결정합니다.""" + 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: @@ -85,14 +151,24 @@ def create_sql_agent_graph() -> StateGraph: graph.set_entry_point("sql_generator") graph.add_edge("sql_generator", "sql_validator") + graph.add_conditional_edges("sql_validator", should_execute_sql, { - "regenerate": "sql_generator", "execute": "sql_executor" + "regenerate": "sql_generator", + "execute": "sql_executor", + "synthesize_failure": "response_synthesizer" }) graph.add_conditional_edges("sql_executor", should_retry_or_respond, { - "regenerate": "sql_generator", "synthesize": "response_synthesizer" + "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() \ No newline at end of file +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 diff --git a/src/services/chatbot_service.py b/src/services/chatbot_service.py index 3a40972..8f9c8a0 100644 --- a/src/services/chatbot_service.py +++ b/src/services/chatbot_service.py @@ -3,7 +3,7 @@ from agents.sql_agent_graph import sql_agent_app from core.db_manager import schema_instance -class ChatbotService: +class ChatbotService(): def __init__(self): self.db_schema = schema_instance @@ -12,7 +12,9 @@ def handle_request(self, user_question: str) -> str: initial_state = { "question": user_question, "chat_history": [], - "db_schema": self.db_schema + "db_schema": self.db_schema, + "validation_error_count": 0, + "execution_error_count": 0 } # 2. 그래프 실행 From 8eb1b1d01ba7ebd0767bf843e1640ba0a4d9f1e2 Mon Sep 17 00:00:00 2001 From: CheckerBoard Date: Sun, 3 Aug 2025 01:09:03 +0900 Subject: [PATCH 32/80] =?UTF-8?q?docs:=20=ED=94=84=EB=A1=AC=ED=94=84?= =?UTF-8?q?=ED=8A=B8=20=ED=8C=8C=EC=9D=BC=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../v1/sql_agent/response_synthesizer.yaml | 25 +++++++++++++++++++ src/prompts/v1/sql_agent/sql_generator.yaml | 19 ++++++++++++++ 2 files changed, 44 insertions(+) create mode 100644 src/prompts/v1/sql_agent/response_synthesizer.yaml create mode 100644 src/prompts/v1/sql_agent/sql_generator.yaml diff --git a/src/prompts/v1/sql_agent/response_synthesizer.yaml b/src/prompts/v1/sql_agent/response_synthesizer.yaml new file mode 100644 index 0000000..94c54b3 --- /dev/null +++ b/src/prompts/v1/sql_agent/response_synthesizer.yaml @@ -0,0 +1,25 @@ +_type: prompt +input_variables: + - question + - context_message +template: | + You are a friendly and helpful database assistant chatbot. + Your goal is to provide a clear and easy-to-understand final answer to the user in Korean. + Please carefully analyze the user's question and the provided context below. + + User's Question: {question} + + Context: + {context_message} + + Instructions: + - If the process was successful: + - Do not just show the raw data from the SQL result. + - Explain what the data means in relation to the user's question. + - Present the answer in a natural, conversational, and polite Korean. + - If the process failed: + - Apologize for the inconvenience. + - Explain the reason for the failure in simple, non-technical terms. + - Gently suggest trying a different or simpler question. + + Final Answer (in Korean): \ No newline at end of file diff --git a/src/prompts/v1/sql_agent/sql_generator.yaml b/src/prompts/v1/sql_agent/sql_generator.yaml new file mode 100644 index 0000000..3b9ca84 --- /dev/null +++ b/src/prompts/v1/sql_agent/sql_generator.yaml @@ -0,0 +1,19 @@ +_type: prompt +input_variables: + - format_instructions + - db_schema + - chat_history + - question + - error_feedback +template: | + You are a powerful text-to-SQL model. + Your role is to generate a SQL query based on the provided database schema and user question. + + Schema: {db_schema} + History: {chat_history} + + {error_feedback} + + Question: {question} + + {format_instructions} \ No newline at end of file From 9ef70fdcd7593838ac9a0eb347e0e80179d76106 Mon Sep 17 00:00:00 2001 From: CheckerBoard Date: Sun, 3 Aug 2025 01:12:28 +0900 Subject: [PATCH 33/80] =?UTF-8?q?feat:=20pyinstaller=20=EB=B9=8C=EB=93=9C?= =?UTF-8?q?=20=EC=A7=80=EC=9B=90=20=EB=B0=8F=20=EC=B5=9C=EC=A2=85=20?= =?UTF-8?q?=EB=8B=B5=EB=B3=80=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/agents/sql_agent_graph.py | 82 ++++++++++++++++++++++------------- 1 file changed, 53 insertions(+), 29 deletions(-) diff --git a/src/agents/sql_agent_graph.py b/src/agents/sql_agent_graph.py index 72b0e3c..52c86ac 100644 --- a/src/agents/sql_agent_graph.py +++ b/src/agents/sql_agent_graph.py @@ -1,14 +1,34 @@ # 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.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") + +# --- 프롬프트 로드 --- +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): @@ -27,7 +47,7 @@ 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: @@ -44,18 +64,13 @@ def sql_generator_node(state: SqlAgentState): Please correct the SQL query based on the error. """ - prompt = f""" - You are a powerful text-to-SQL model. Your role is to generate a SQL query based on the provided database schema and user question. - - {parser.get_format_instructions()} - - Schema: {state['db_schema']} - History: {state['chat_history']} - - {error_feedback} - - Question: {state['question']} - """ + 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) @@ -98,29 +113,39 @@ def sql_executor_node(state: SqlAgentState): def response_synthesizer_node(state: SqlAgentState): print("--- 4. 최종 답변 생성 중 ---") - if state.get('validation_error_count', 0) >= MAX_ERROR_COUNT: - message = f"SQL 검증에 {MAX_ERROR_COUNT}회 이상 실패했습니다. 마지막 오류: {state.get('validation_error')}" - elif state.get('execution_error_count', 0) >= MAX_ERROR_COUNT: - message = f"SQL 실행에 {MAX_ERROR_COUNT}회 이상 실패했습니다. 마지막 오류: {state.get('execution_result')}" + 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: - message = f"SQL Result: {state['execution_result']}" + 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 = f""" - Question: {state['question']} - SQL: {state['sql_query']} - {message} - - Based on the information above, provide a final answer to the user in Korean. - If there was an error, explain the problem to the user in a friendly way. - 사용자 질문과 쿼리가 어떤 관계가 있는지 같이 설명해 - """ + prompt = RESPONSE_SYNTHESIZER_PROMPT.format( + question=state['question'], + context_message=context_message + ) response = llm_instance.invoke(prompt) state['final_response'] = response.content return state # --- 엣지 함수 정의 --- def should_execute_sql(state: SqlAgentState): - """SQL 검증 후, 실행할지/재생성할지/포기할지 결정합니다.""" if state.get("validation_error_count", 0) >= MAX_ERROR_COUNT: print(f"--- 검증 실패 {MAX_ERROR_COUNT}회 초과: 답변 생성으로 이동 ---") return "synthesize_failure" @@ -131,7 +156,6 @@ def should_execute_sql(state: SqlAgentState): return "execute" def should_retry_or_respond(state: SqlAgentState): - """SQL 실행 후, 성공/재시도/포기 여부를 결정합니다.""" if state.get("execution_error_count", 0) >= MAX_ERROR_COUNT: print(f"--- 실행 실패 {MAX_ERROR_COUNT}회 초과: 답변 생성으로 이동 ---") return "synthesize_failure" From 216b7ae6554a019b8e0ec978717fa86bab0ac364 Mon Sep 17 00:00:00 2001 From: CheckerBoard Date: Tue, 5 Aug 2025 22:50:23 +0900 Subject: [PATCH 34/80] =?UTF-8?q?chore:=20cryptography=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- requirements.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/requirements.txt b/requirements.txt index 892ea35..92d159d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -80,6 +80,7 @@ pyzmq==27.0.0 regex==2024.11.6 requests==2.32.4 requests-toolbelt==1.0.0 +setuptools==80.9.0 six==1.17.0 sniffio==1.3.1 SQLAlchemy==2.0.41 From ae1609b88cf007259fdbb51c43da41215e29aad1 Mon Sep 17 00:00:00 2001 From: CheckerBoard Date: Tue, 5 Aug 2025 23:16:14 +0900 Subject: [PATCH 35/80] =?UTF-8?q?feat:=20=EC=9D=98=EB=8F=84=20=EB=B6=84?= =?UTF-8?q?=EB=A5=98=20=EB=85=B8=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 --- src/agents/sql_agent_graph.py | 40 +++++++++++++++++++++++++++++++++-- 1 file changed, 38 insertions(+), 2 deletions(-) diff --git a/src/agents/sql_agent_graph.py b/src/agents/sql_agent_graph.py index 52c86ac..6e39284 100644 --- a/src/agents/sql_agent_graph.py +++ b/src/agents/sql_agent_graph.py @@ -6,6 +6,7 @@ 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 @@ -27,6 +28,7 @@ def resource_path(relative_path): 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"))) 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"))) @@ -35,6 +37,7 @@ 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 @@ -43,6 +46,18 @@ class SqlAgentState(TypedDict): 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 sql_generator_node(state: SqlAgentState): print("--- 1. SQL 생성 중 ---") parser = PydanticOutputParser(pydantic_object=SqlQuery) @@ -73,7 +88,7 @@ def sql_generator_node(state: SqlAgentState): ) response = llm_instance.invoke(prompt) - parsed_query = parser.invoke(response) + parsed_query = parser.invoke(response.content) state['sql_query'] = parsed_query.query state['validation_error'] = None state['execution_result'] = None @@ -145,6 +160,13 @@ def response_synthesizer_node(state: SqlAgentState): return state # --- 엣지 함수 정의 --- +def route_after_intent_classification(state: SqlAgentState): + if state['intent'] == "SQL": + print("--- 의도: SQL 관련 질문 ---") + return "sql_generator" + 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}회 초과: 답변 생성으로 이동 ---") @@ -168,12 +190,26 @@ def should_retry_or_respond(state: SqlAgentState): # --- 그래프 구성 --- def create_sql_agent_graph() -> StateGraph: graph = StateGraph(SqlAgentState) + + graph.add_node("intent_classifier", intent_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("sql_generator") + graph.set_entry_point("intent_classifier") + + graph.add_conditional_edges( + "intent_classifier", + route_after_intent_classification, + { + "sql_generator": "sql_generator", + "unsupported_question": "unsupported_question" + } + ) + graph.add_edge("unsupported_question", END) + graph.add_edge("sql_generator", "sql_validator") graph.add_conditional_edges("sql_validator", should_execute_sql, { From 1fe69ba2678329c2f6eb4c61e81d7cff892e9a87 Mon Sep 17 00:00:00 2001 From: CheckerBoard Date: Tue, 5 Aug 2025 23:16:31 +0900 Subject: [PATCH 36/80] =?UTF-8?q?docs:=20=EC=9D=98=EB=8F=84=20=EB=B6=84?= =?UTF-8?q?=EB=A5=98=20=ED=94=84=EB=A1=AC=ED=94=84=ED=8A=B8=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../v1/sql_agent/intent_classifier.yaml | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 src/prompts/v1/sql_agent/intent_classifier.yaml diff --git a/src/prompts/v1/sql_agent/intent_classifier.yaml b/src/prompts/v1/sql_agent/intent_classifier.yaml new file mode 100644 index 0000000..1c2be08 --- /dev/null +++ b/src/prompts/v1/sql_agent/intent_classifier.yaml @@ -0,0 +1,30 @@ + +_type: prompt +input_variables: + - question +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. + + - 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". + + Example 1: + Question: "Show me the list of users who signed up last month." + Classification: SQL + + Example 2: + Question: "What is the total revenue for the last quarter?" + Classification: SQL + + Example 3: + Question: "Hello, who are you?" + Classification: non-SQL + + Example 4: + Question: "What is the weather like today?" + Classification: non-SQL + + Now, classify the following question: + Question: {question} + Classification: From 3015c567dd9835bbfbe6c3e18724cc5f9b36f8d3 Mon Sep 17 00:00:00 2001 From: CheckerBoard Date: Sun, 10 Aug 2025 13:21:11 +0900 Subject: [PATCH 37/80] =?UTF-8?q?feat:=20db=20=EB=B6=84=EB=A5=98=20?= =?UTF-8?q?=EB=85=B8=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 --- src/agents/sql_agent_graph.py | 53 +++++++++++++++++++++++++++++++++-- 1 file changed, 51 insertions(+), 2 deletions(-) diff --git a/src/agents/sql_agent_graph.py b/src/agents/sql_agent_graph.py index 6e39284..8ce18b8 100644 --- a/src/agents/sql_agent_graph.py +++ b/src/agents/sql_agent_graph.py @@ -29,6 +29,7 @@ def resource_path(relative_path): # --- 프롬프트 로드 --- 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"))) @@ -58,6 +59,51 @@ def unsupported_question_node(state: SqlAgentState): 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, + "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) @@ -163,7 +209,7 @@ def response_synthesizer_node(state: SqlAgentState): def route_after_intent_classification(state: SqlAgentState): if state['intent'] == "SQL": print("--- 의도: SQL 관련 질문 ---") - return "sql_generator" + return "db_classifier" print("--- 의도: SQL과 관련 없는 질문 ---") return "unsupported_question" @@ -192,6 +238,7 @@ 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) @@ -204,11 +251,13 @@ def create_sql_agent_graph() -> StateGraph: "intent_classifier", route_after_intent_classification, { - "sql_generator": "sql_generator", + "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") From b9b4238c2b0504353bc22b815e79da7ae3f801c0 Mon Sep 17 00:00:00 2001 From: CheckerBoard Date: Sun, 10 Aug 2025 13:21:27 +0900 Subject: [PATCH 38/80] =?UTF-8?q?docs:=20db=20=EB=B6=84=EB=A5=98=20?= =?UTF-8?q?=ED=94=84=EB=A1=AC=ED=94=84=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/prompts/v1/sql_agent/db_classifier.yaml | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 src/prompts/v1/sql_agent/db_classifier.yaml diff --git a/src/prompts/v1/sql_agent/db_classifier.yaml b/src/prompts/v1/sql_agent/db_classifier.yaml new file mode 100644 index 0000000..b4e2270 --- /dev/null +++ b/src/prompts/v1/sql_agent/db_classifier.yaml @@ -0,0 +1,15 @@ +_type: "prompt" +input_variables: + - db_options + - question +template: | + Based on the user's question, which of the following databases is most likely to contain the answer? + Please respond with only the database name. + + Available databases: + {db_options} + + User Question: + {question} + + Selected Database: \ No newline at end of file From b5dcda179fac7817ea5b6f93393eb6f6f1aa863f Mon Sep 17 00:00:00 2001 From: CheckerBoard Date: Thu, 14 Aug 2025 01:51:47 +0900 Subject: [PATCH 39/80] =?UTF-8?q?refactor:=20=ED=8C=8C=EC=9D=BC=EB=AA=85?= =?UTF-8?q?=20=EC=88=98=EC=A0=95=20=EB=B0=8F=20=ED=8C=8C=EC=9D=BC=20?= =?UTF-8?q?=EC=9C=84=EC=B9=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/v1/endpoints/chat.py | 2 +- src/api/v1/{schemas.py => schemas/chatbot_schemas.py} | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) rename src/api/v1/{schemas.py => schemas/chatbot_schemas.py} (76%) diff --git a/src/api/v1/endpoints/chat.py b/src/api/v1/endpoints/chat.py index e3ca8dc..09aa2d3 100644 --- a/src/api/v1/endpoints/chat.py +++ b/src/api/v1/endpoints/chat.py @@ -1,7 +1,7 @@ # src/api/v1/endpoints/chat.py from fastapi import APIRouter, Depends -from api.v1.schemas import ChatRequest, ChatResponse +from api.v1.schemas.chatbot_schemas import ChatRequest, ChatResponse from services.chatbot_service import ChatbotService router = APIRouter() diff --git a/src/api/v1/schemas.py b/src/api/v1/schemas/chatbot_schemas.py similarity index 76% rename from src/api/v1/schemas.py rename to src/api/v1/schemas/chatbot_schemas.py index 0d57177..0db3a79 100644 --- a/src/api/v1/schemas.py +++ b/src/api/v1/schemas/chatbot_schemas.py @@ -1,4 +1,4 @@ -# src/api/v1/schemas.py +# src/api/v1/schemas/chatbot_schemas.py from pydantic import BaseModel From f9c9acf8153d690ccb9a59847bf71cca1a09b26f Mon Sep 17 00:00:00 2001 From: CheckerBoard Date: Thu, 14 Aug 2025 01:52:14 +0900 Subject: [PATCH 40/80] =?UTF-8?q?feat:=20annotator=20=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=84=B0=20=ED=83=80=EC=9E=85=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/v1/schemas/annotator_schemas.py | 47 +++++++++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 src/api/v1/schemas/annotator_schemas.py diff --git a/src/api/v1/schemas/annotator_schemas.py b/src/api/v1/schemas/annotator_schemas.py new file mode 100644 index 0000000..7db3174 --- /dev/null +++ b/src/api/v1/schemas/annotator_schemas.py @@ -0,0 +1,47 @@ +# 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] From 717babf39de8880d3d3dae8d2bea1258a77de392 Mon Sep 17 00:00:00 2001 From: CheckerBoard Date: Thu, 14 Aug 2025 01:52:29 +0900 Subject: [PATCH 41/80] =?UTF-8?q?feat:=20annotator=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/services/annotation_service.py | 100 +++++++++++++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 src/services/annotation_service.py diff --git a/src/services/annotation_service.py b/src/services/annotation_service.py new file mode 100644 index 0000000..4e0e823 --- /dev/null +++ b/src/services/annotation_service.py @@ -0,0 +1,100 @@ +# 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 eb9603ec977cd2e4d230661e2c02cd0c9c68ea4e Mon Sep 17 00:00:00 2001 From: CheckerBoard Date: Thu, 14 Aug 2025 01:52:48 +0900 Subject: [PATCH 42/80] =?UTF-8?q?feat:=20annotation=20endpoint=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/v1/endpoints/annotator.py | 24 ++++++++++++++++++++++++ src/main.py | 9 ++++++++- 2 files changed, 32 insertions(+), 1 deletion(-) create mode 100644 src/api/v1/endpoints/annotator.py diff --git a/src/api/v1/endpoints/annotator.py b/src/api/v1/endpoints/annotator.py new file mode 100644 index 0000000..508fb94 --- /dev/null +++ b/src/api/v1/endpoints/annotator.py @@ -0,0 +1,24 @@ + +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/main.py b/src/main.py index 98c0984..b25548d 100644 --- a/src/main.py +++ b/src/main.py @@ -4,7 +4,7 @@ from contextlib import closing import uvicorn from fastapi import FastAPI -from api.v1.endpoints import chat +from api.v1.endpoints import chat, annotator def find_free_port(): """사용 가능한 비어있는 포트를 찾는 함수""" @@ -27,6 +27,13 @@ def find_free_port(): tags=["Chatbot"] ) +# '/api/v1' 경로에 annotator 라우터 포함 +app.include_router( + annotator.router, + prefix="/api/v1", + tags=["Annotator"] +) + @app.get("/") def health_check(): """헬스체크 엔드포인트, 서버 상태가 정상이면 'ok' 반환합니다.""" From 06ef13963101667e4b8210ef2b6c7401b87e8fdd Mon Sep 17 00:00:00 2001 From: CheckerBoard Date: Thu, 14 Aug 2025 02:25:32 +0900 Subject: [PATCH 43/80] =?UTF-8?q?docs:=20=ED=94=84=EB=A1=AC=ED=94=84?= =?UTF-8?q?=ED=8A=B8=EC=97=90=20=EC=B1=84=ED=8C=85=20=EB=82=B4=EC=97=AD=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/prompts/v1/sql_agent/db_classifier.yaml | 4 ++++ src/prompts/v1/sql_agent/response_synthesizer.yaml | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/src/prompts/v1/sql_agent/db_classifier.yaml b/src/prompts/v1/sql_agent/db_classifier.yaml index b4e2270..aa89aaa 100644 --- a/src/prompts/v1/sql_agent/db_classifier.yaml +++ b/src/prompts/v1/sql_agent/db_classifier.yaml @@ -1,6 +1,7 @@ _type: "prompt" input_variables: - db_options + - chat_history - question template: | Based on the user's question, which of the following databases is most likely to contain the answer? @@ -8,6 +9,9 @@ template: | Available databases: {db_options} + + conversation History: + {chat_history} User Question: {question} diff --git a/src/prompts/v1/sql_agent/response_synthesizer.yaml b/src/prompts/v1/sql_agent/response_synthesizer.yaml index 94c54b3..2fd970c 100644 --- a/src/prompts/v1/sql_agent/response_synthesizer.yaml +++ b/src/prompts/v1/sql_agent/response_synthesizer.yaml @@ -1,6 +1,7 @@ _type: prompt input_variables: - question + - chat_history - context_message template: | You are a friendly and helpful database assistant chatbot. @@ -11,6 +12,9 @@ template: | Context: {context_message} + + conversation History: + {chat_history} Instructions: - If the process was successful: From 3a80f615d20b0dd2086aeab04f875ada33200fb2 Mon Sep 17 00:00:00 2001 From: CheckerBoard Date: Thu, 14 Aug 2025 02:27:25 +0900 Subject: [PATCH 44/80] =?UTF-8?q?feat:=20=EC=B1=97=EB=B4=87=20=EB=A9=80?= =?UTF-8?q?=ED=8B=B0=ED=84=B4=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/agents/sql_agent_graph.py | 2 ++ src/api/v1/endpoints/chat.py | 3 ++- src/api/v1/schemas/chatbot_schemas.py | 9 +++++++- src/services/chatbot_service.py | 30 ++++++++++++++++++--------- 4 files changed, 32 insertions(+), 12 deletions(-) diff --git a/src/agents/sql_agent_graph.py b/src/agents/sql_agent_graph.py index 8ce18b8..71d59b0 100644 --- a/src/agents/sql_agent_graph.py +++ b/src/agents/sql_agent_graph.py @@ -91,6 +91,7 @@ def db_classifier_node(state: SqlAgentState): chain = DB_CLASSIFIER_PROMPT | llm_instance | StrOutputParser() selected_db_name = chain.invoke({ "db_options": db_options, + "chat_history": state['chat_history'], "question": state['question'] }) @@ -199,6 +200,7 @@ def response_synthesizer_node(state: SqlAgentState): prompt = RESPONSE_SYNTHESIZER_PROMPT.format( question=state['question'], + chat_history=state['chat_history'], context_message=context_message ) response = llm_instance.invoke(prompt) diff --git a/src/api/v1/endpoints/chat.py b/src/api/v1/endpoints/chat.py index 09aa2d3..69ee393 100644 --- a/src/api/v1/endpoints/chat.py +++ b/src/api/v1/endpoints/chat.py @@ -23,5 +23,6 @@ def handle_chat_request( Returns: ChatRespone: 챗봇 응답 """ - final_answer = service.handle_request(request.question) + 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/schemas/chatbot_schemas.py b/src/api/v1/schemas/chatbot_schemas.py index 0db3a79..f3ae457 100644 --- a/src/api/v1/schemas/chatbot_schemas.py +++ b/src/api/v1/schemas/chatbot_schemas.py @@ -1,9 +1,16 @@ # 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 \ No newline at end of file + answer: str diff --git a/src/services/chatbot_service.py b/src/services/chatbot_service.py index 8f9c8a0..3a17cfc 100644 --- a/src/services/chatbot_service.py +++ b/src/services/chatbot_service.py @@ -1,24 +1,34 @@ # src/services/chatbot_service.py from agents.sql_agent_graph import sql_agent_app -from core.db_manager import schema_instance +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(): - def __init__(self): - self.db_schema = schema_instance + # TODO: schema API 요청 + # def __init__(self): + # self.db_schema = schema_instance - def handle_request(self, user_question: str) -> str: - # 1. 에이전트 그래프에 전달할 초기 상태 구성 + 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": [], - "db_schema": self.db_schema, + "chat_history": langchain_messages, + # "db_schema": self.db_schema, "validation_error_count": 0, "execution_error_count": 0 } - # 2. 그래프 실행 final_state = sql_agent_app.invoke(initial_state) - final_response = final_state['final_response'] - return final_response \ No newline at end of file + return final_state['final_response'] \ No newline at end of file From ff6e4377b6fe0da0ea6f65d336ffe24287e1df27 Mon Sep 17 00:00:00 2001 From: CheckerBoard Date: Sat, 16 Aug 2025 23:02:41 +0900 Subject: [PATCH 45/80] =?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 46/80] =?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 47/80] =?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 48/80] =?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 49/80] =?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 50/80] =?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 51/80] =?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 52/80] =?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 53/80] =?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 54/80] =?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 55/80] =?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 56/80] =?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 57/80] =?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 58/80] =?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 59/80] =?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 60/80] =?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 61/80] =?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 62/80] =?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 63/80] =?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 64/80] =?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 65/80] =?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 66/80] =?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 67/80] =?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 68/80] =?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 69/80] =?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 70/80] =?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 71/80] =?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 72/80] =?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 73/80] =?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 74/80] =?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__": From 620d3a4ade4e955fd09822992dd2bbd276dcd0e1 Mon Sep 17 00:00:00 2001 From: CheckerBoard Date: Tue, 19 Aug 2025 21:19:11 +0900 Subject: [PATCH 75/80] =?UTF-8?q?fix:=20API=20=EA=B2=BD=EB=A1=9C=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/clients/api_client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/clients/api_client.py b/src/core/clients/api_client.py index 1c2c9a4..2d01029 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/result", + f"{self.base_url}/api/keys/find", headers=self.headers, timeout=httpx.Timeout(10.0) ) From 1bf5323758ab0fd9a5037b785ef7386f8734f689 Mon Sep 17 00:00:00 2001 From: CheckerBoard Date: Wed, 20 Aug 2025 00:28:21 +0900 Subject: [PATCH 76/80] =?UTF-8?q?chore:=20requirements.txt=EC=97=90=20cffi?= =?UTF-8?q?,=20cryptography,=20pycparser=20=ED=8C=A8=ED=82=A4=EC=A7=80=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 --- requirements.txt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/requirements.txt b/requirements.txt index 92d159d..e484c4d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,9 +9,11 @@ asttokens==3.0.0 attrs==25.3.0 blinker==1.9.0 certifi==2025.6.15 +cffi==1.17.1 charset-normalizer==3.4.2 click==8.2.1 comm==0.2.2 +cryptography==45.0.5 dataclasses-json==0.6.7 debugpy==1.8.14 decorator==5.2.1 @@ -66,6 +68,7 @@ propcache==0.3.2 psutil==7.0.0 ptyprocess==0.7.0 pure_eval==0.2.3 +pycparser==2.22 pydantic==2.11.7 pydantic-settings==2.10.1 pydantic_core==2.33.2 From a8f4faad15667bb3cbe0396effd4bf2938327643 Mon Sep 17 00:00:00 2001 From: CheckerBoard Date: Wed, 20 Aug 2025 00:29:18 +0900 Subject: [PATCH 77/80] =?UTF-8?q?feat:=20DB=20=EC=96=B4=EB=85=B8=ED=85=8C?= =?UTF-8?q?=EC=9D=B4=EC=85=98=20API=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EC=96=B4=EB=85=B8=ED=85=8C?= =?UTF-8?q?=EC=9D=B4=EC=85=98=20=EC=83=9D=EC=84=B1=20=EB=A1=9C=EC=A7=81=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 --- test_services.py | 65 +++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 59 insertions(+), 6 deletions(-) diff --git a/test_services.py b/test_services.py index 5b66de8..2fb2b4a 100644 --- a/test_services.py +++ b/test_services.py @@ -55,6 +55,52 @@ async def test_api_client(): except Exception as e: print(f"❌ API Client 테스트 실패: {e}") +async def test_db_annotation_api(): + """DB 어노테이션 API 테스트""" + print("\n🔍 DB 어노테이션 API 테스트 중...") + try: + from services.database.database_service import get_database_service + + service = await get_database_service() + + # DB 프로필 조회 테스트 + try: + profiles = await service.get_db_profiles() + print(f"✅ DB 프로필 조회 성공: {len(profiles)}개") + + if profiles: + print(f"📝 첫 번째 프로필: {profiles[0].type} - {profiles[0].view_name or 'No view name'}") + + # 첫 번째 프로필의 어노테이션 조회 테스트 + try: + annotations = await service.get_db_annotations(profiles[0].id) + print(f"✅ 어노테이션 조회 성공: {profiles[0].id}") + except Exception as e: + print(f"⚠️ 어노테이션 조회 실패: {e}") + + # 통합 조회 테스트 + try: + dbs_with_annotations = await service.get_databases_with_annotations() + print(f"✅ 통합 조회 성공: {len(dbs_with_annotations)}개") + + if dbs_with_annotations: + first_db = dbs_with_annotations[0] + print(f"📝 첫 번째 DB 정보:") + print(f" - Display Name: {first_db['display_name']}") + print(f" - Description: {first_db['description']}") + print(f" - Has Annotations: {'data' in first_db['annotations']}") + + except Exception as e: + print(f"⚠️ 통합 조회 실패: {e}") + else: + print("⚠️ DB 프로필이 없습니다.") + + except Exception as e: + print(f"⚠️ DB 프로필 조회 실패: {e}") + + except Exception as e: + print(f"❌ DB 어노테이션 API 테스트 실패: {e}") + async def test_database_service(): """Database Service 테스트""" print("\n🔍 Database Service 테스트 중...") @@ -197,18 +243,23 @@ async def test_annotation_functionality(): Column(column_name="name", data_type="varchar"), Column(column_name="email", data_type="varchar") ], - sample_rows=["1, John Doe, john@example.com"] + sample_rows=[{"id": 1, "name": "John Doe", "email": "john@example.com"}] ) ], relationships=[] ) try: - result = await service.generate_annotations(sample_database) + from schemas.api.annotator_schemas import AnnotationRequest + request = AnnotationRequest( + dbms_type="MySQL", + databases=[sample_database] + ) + result = await service.generate_for_schema(request) print(f"✅ 어노테이션 생성 성공") - print(f"📝 생성된 테이블 수: {len(result.tables)}") - if result.tables: - print(f"📝 첫 번째 테이블 설명: {result.tables[0].description[:100]}...") + print(f"📝 생성된 데이터베이스 수: {len(result.databases)}") + if result.databases and result.databases[0].tables: + print(f"📝 첫 번째 테이블 설명: {result.databases[0].tables[0].description[:100]}...") except Exception as e: print(f"⚠️ 어노테이션 생성 실패: {e}") @@ -239,8 +290,9 @@ async def main(): # 기본 서비스 테스트 await test_llm_provider() await test_api_client() + await test_annotation_service() + await test_db_annotation_api() # 새로운 DB 어노테이션 API 테스트 추가 await test_database_service() - await test_annotation_service() await test_chatbot_service() await test_sql_agent() @@ -250,6 +302,7 @@ async def main(): client = await get_api_client() if await client.health_check(): print("\n🧪 확장 테스트 시작 (백엔드 연결 확인됨)") + print("⚠️ 참고: 데이터베이스 API가 구현되지 않아 일부 테스트는 실패할 수 있습니다") await test_end_to_end_chat() await test_annotation_functionality() await test_error_scenarios() From 4a712626e0ebefcd72eac468f9ae38f0d5e40033 Mon Sep 17 00:00:00 2001 From: CheckerBoard Date: Wed, 20 Aug 2025 00:30:32 +0900 Subject: [PATCH 78/80] =?UTF-8?q?feat:=20=EB=B0=B1=EC=97=94=EB=93=9C=20?= =?UTF-8?q?=EC=84=9C=EB=B2=84=20=EC=97=B0=EA=B2=B0=20=EC=B2=B4=ED=81=AC=20?= =?UTF-8?q?=EB=B0=8F=20LLM=20=EC=A7=80=EC=97=B0=20=EC=B4=88=EA=B8=B0?= =?UTF-8?q?=ED=99=94=20=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/core/providers/llm_provider.py | 32 +++++++++++++++++++++++++++--- src/main.py | 17 ++++++++++++++-- 2 files changed, 44 insertions(+), 5 deletions(-) diff --git a/src/core/providers/llm_provider.py b/src/core/providers/llm_provider.py index 80e4c60..0980a34 100644 --- a/src/core/providers/llm_provider.py +++ b/src/core/providers/llm_provider.py @@ -10,7 +10,10 @@ logger = logging.getLogger(__name__) class LLMProvider: - """LLM 제공자를 관리하는 클래스""" + """ + LLM 제공자를 관리하는 클래스 + 지연 초기화를 지원하여 BE 서버가 늦게 시작되어도 작동합니다. + """ def __init__(self, model_name: str = "gpt-4o-mini", temperature: float = 0): self.model_name = model_name @@ -18,6 +21,8 @@ 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._initialization_attempted: bool = False + self._initialization_failed: bool = False async def _load_api_key(self) -> str: """백엔드에서 OpenAI API 키를 로드합니다.""" @@ -34,9 +39,28 @@ async def _load_api_key(self) -> str: raise ValueError("백엔드에서 OpenAI API 키를 가져올 수 없습니다. 백엔드 서버를 확인해주세요.") async def get_llm(self) -> ChatOpenAI: - """LLM 인스턴스를 비동기적으로 반환합니다.""" + """ + ChatOpenAI 인스턴스를 반환합니다. + 지연 초기화를 통해 BE 서버 연결이 실패해도 재시도합니다. + """ if self._llm is None: - self._llm = await self._create_llm() + # 이전에 초기화를 시도했고 실패했다면 재시도 + if self._initialization_failed: + logger.info("🔄 LLM 초기화 재시도 중...") + self._initialization_failed = False + self._initialization_attempted = False + + try: + self._initialization_attempted = True + self._llm = await self._create_llm() + self._initialization_failed = False + logger.info("✅ LLM 초기화 성공") + + except Exception as e: + self._initialization_failed = True + logger.error(f"❌ LLM 초기화 실패: {e}") + raise RuntimeError(f"LLM을 초기화할 수 없습니다. 백엔드 서버가 실행 중인지 확인해주세요: {e}") + return self._llm async def _create_llm(self) -> ChatOpenAI: @@ -67,6 +91,8 @@ async def refresh_api_key(self): """API 키를 새로고침합니다.""" self._api_key = None self._llm = None # LLM 인스턴스도 재생성 + self._initialization_attempted = False + self._initialization_failed = False logger.info("API key refreshed") async def test_connection(self) -> bool: diff --git a/src/main.py b/src/main.py index d023e72..672f01d 100644 --- a/src/main.py +++ b/src/main.py @@ -18,9 +18,22 @@ async def lifespan(app: FastAPI): """애플리케이션 라이프사이클 관리""" logger.info("QGenie AI Chatbot 시작 중...") - # 시작 시 초기화 작업 + # 시작 시 BE 서버 연결 체크 + try: + from core.clients.api_client import get_api_client + api_client = await get_api_client() + + # BE 서버 상태 확인 + if await api_client.health_check(): + logger.info("✅ 백엔드 서버 연결 성공") + else: + logger.warning("⚠️ 백엔드 서버에 연결할 수 없습니다. 첫 요청 시 연결을 재시도합니다.") + + except Exception as e: + logger.warning(f"⚠️ 백엔드 서버 초기 연결 실패: {e}") + logger.info("🔄 서비스는 지연 초기화 모드로 시작됩니다.") + try: - # 필요한 경우 여기에 초기화 로직 추가 logger.info("애플리케이션 초기화 완료") yield finally: From e0958ebd7af2d34e4694735385dc937cfb3f81b5 Mon Sep 17 00:00:00 2001 From: CheckerBoard Date: Wed, 20 Aug 2025 00:30:50 +0900 Subject: [PATCH 79/80] =?UTF-8?q?feat:=20DBMS=20=ED=94=84=EB=A1=9C?= =?UTF-8?q?=ED=95=84=20=EB=B0=8F=20=EC=96=B4=EB=85=B8=ED=85=8C=EC=9D=B4?= =?UTF-8?q?=EC=85=98=20=EC=A1=B0=ED=9A=8C=20=EB=A1=9C=EC=A7=81=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80,=20=EB=8D=B0=EC=9D=B4=ED=84=B0=EB=B2=A0=EC=9D=B4?= =?UTF-8?q?=EC=8A=A4=20=EC=84=A0=ED=83=9D=20=EB=B0=8F=20=EC=8A=A4=ED=82=A4?= =?UTF-8?q?=EB=A7=88=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/agents/sql_agent/nodes.py | 119 +++++++++++---- src/agents/sql_agent/state.py | 4 +- src/core/clients/api_client.py | 92 ++++++++++- src/services/database/database_service.py | 176 ++++++++++++++++------ 4 files changed, 311 insertions(+), 80 deletions(-) diff --git a/src/agents/sql_agent/nodes.py b/src/agents/sql_agent/nodes.py index b381c17..dd2e916 100644 --- a/src/agents/sql_agent/nodes.py +++ b/src/agents/sql_agent/nodes.py @@ -101,35 +101,69 @@ async def db_classifier_node(self, state: SqlAgentState) -> SqlAgentState: print("--- 0.5. DB 분류 중 ---") try: - # 데이터베이스 목록 가져오기 - available_dbs = await self.database_service.get_available_databases() + # DBMS 프로필과 어노테이션을 함께 조회 + available_dbs_with_annotations = await self.database_service.get_databases_with_annotations() - if not available_dbs: - raise DatabaseConnectionException("사용 가능한 데이터베이스가 없습니다.") + if not available_dbs_with_annotations: + raise DatabaseConnectionException("사용 가능한 DBMS가 없습니다.") - # 데이터베이스 옵션 생성 + print(f"--- {len(available_dbs_with_annotations)}개의 DBMS 발견 ---") + + # 어노테이션 정보를 포함한 DBMS 옵션 생성 db_options = "\n".join([ - f"- {db.database_name}: {db.description}" - for db in available_dbs + f"- {db['display_name']}: {db['description']}" + for db in available_dbs_with_annotations ]) - # LLM을 사용하여 적절한 데이터베이스 선택 + # LLM을 사용하여 적절한 DBMS 선택 llm = await self.llm_provider.get_llm() chain = self.db_classifier_prompt | llm | StrOutputParser() - selected_db_name = await chain.ainvoke({ + selected_db_display_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 + selected_db_display_name = selected_db_display_name.strip() + + # 선택된 display_name으로 실제 DBMS 정보 찾기 + selected_db_info = None + for db in available_dbs_with_annotations: + if db['display_name'] == selected_db_display_name: + selected_db_info = db + break + + if not selected_db_info: + # 부분 매칭 시도 + for db in available_dbs_with_annotations: + if selected_db_display_name in db['display_name'] or db['display_name'] in selected_db_display_name: + selected_db_info = db + break + + if not selected_db_info: + print(f"--- 선택된 DBMS를 찾을 수 없음: {selected_db_display_name}, 첫 번째 DBMS 사용 ---") + selected_db_info = available_dbs_with_annotations[0] + + state['selected_db'] = selected_db_info['display_name'] + state['selected_db_profile'] = selected_db_info['profile'] + state['selected_db_annotations'] = selected_db_info['annotations'] + + print(f'--- 선택된 DBMS: {selected_db_info["display_name"]} ---') + print(f'--- DBMS 프로필 ID: {selected_db_info["profile"]["id"]} ---') + + # 어노테이션 정보를 스키마로 사용 + if selected_db_info['annotations'] and 'data' in selected_db_info['annotations']: + schema_info = self._convert_annotations_to_schema(selected_db_info['annotations']) + state['db_schema'] = schema_info + print(f"--- 어노테이션 기반 스키마 사용 ---") + else: + # 어노테이션이 없는 경우 기본 정보로 대체 + schema_info = f"DBMS 유형: {selected_db_info['profile']['type']}\n" + schema_info += f"호스트: {selected_db_info['profile']['host']}\n" + schema_info += f"포트: {selected_db_info['profile']['port']}\n" + schema_info += "상세 스키마 정보가 없습니다. 기본 SQL 구문을 사용하세요." + state['db_schema'] = schema_info + print(f"--- 기본 DBMS 정보 사용 ---") return state @@ -141,6 +175,35 @@ async def db_classifier_node(self, state: SqlAgentState) -> SqlAgentState: # 폴백 없이 에러를 다시 발생시킴 raise e + def _convert_annotations_to_schema(self, annotations: dict) -> str: + """어노테이션 데이터를 스키마 문자열로 변환합니다.""" + try: + if not annotations or 'data' not in annotations: + return "어노테이션 스키마 정보가 없습니다." + + # 어노테이션 구조에 따라 스키마 정보 추출 + # 실제 어노테이션 응답 구조를 확인 후 구현 필요 + schema_parts = [] + schema_parts.append("=== 어노테이션 기반 스키마 정보 ===") + + annotation_data = annotations.get('data', {}) + + # 어노테이션 데이터가 데이터베이스 정보를 포함하는 경우 + if isinstance(annotation_data, dict): + for key, value in annotation_data.items(): + schema_parts.append(f"{key}: {str(value)[:200]}...") + elif isinstance(annotation_data, list): + for i, item in enumerate(annotation_data): + schema_parts.append(f"항목 {i+1}: {str(item)[:200]}...") + else: + schema_parts.append(f"어노테이션 데이터: {str(annotation_data)[:500]}...") + + return "\n".join(schema_parts) + + except Exception as e: + print(f"어노테이션 변환 중 오류: {e}") + return f"어노테이션 변환 실패: {e}" + async def sql_generator_node(self, state: SqlAgentState) -> SqlAgentState: """SQL 쿼리를 생성하는 노드""" print("--- 1. SQL 생성 중 ---") @@ -234,7 +297,15 @@ 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') + + # 선택된 DB 프로필에서 실제 DB ID 가져오기 + db_profile = state.get('selected_db_profile') + if db_profile and 'id' in db_profile: + user_db_id = db_profile['id'] + print(f"--- 실행용 DB 프로필 ID: {user_db_id} ---") + else: + user_db_id = 'TEST-USER-DB-12345' # 폴백 + print(f"--- DB 프로필이 없어 테스트 ID 사용: {user_db_id} ---") result = await self.database_service.execute_query( state['sql_query'], @@ -256,17 +327,7 @@ async def sql_executor_node(self, state: SqlAgentState) -> SqlAgentState: 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 - ) - + # 실행 실패 시에도 상태를 반환하여 엣지에서 판단하도록 함 return state async def response_synthesizer_node(self, state: SqlAgentState) -> SqlAgentState: diff --git a/src/agents/sql_agent/state.py b/src/agents/sql_agent/state.py index ab6aa1c..fdf909e 100644 --- a/src/agents/sql_agent/state.py +++ b/src/agents/sql_agent/state.py @@ -1,6 +1,6 @@ # src/agents/sql_agent/state.py -from typing import List, TypedDict, Optional +from typing import List, TypedDict, Optional, Dict, Any from langchain_core.messages import BaseMessage class SqlAgentState(TypedDict): @@ -13,6 +13,8 @@ class SqlAgentState(TypedDict): # 데이터베이스 관련 selected_db: Optional[str] db_schema: str + selected_db_profile: Optional[Dict[str, Any]] # DB 프로필 정보 + selected_db_annotations: Optional[Dict[str, Any]] # DB 어노테이션 정보 # 의도 분류 결과 intent: str diff --git a/src/core/clients/api_client.py b/src/core/clients/api_client.py index 2d01029..48b0f3b 100644 --- a/src/core/clients/api_client.py +++ b/src/core/clients/api_client.py @@ -15,6 +15,24 @@ class DatabaseInfo(BaseModel): database_name: str description: str +class DBProfileInfo(BaseModel): + """DB 프로필 정보 모델""" + id: str + type: str + host: Optional[str] = None + port: Optional[int] = None + name: Optional[str] = None + username: Optional[str] = None + view_name: Optional[str] = None + created_at: str + updated_at: str + +class DBProfileResponse(BaseModel): + """DB 프로필 조회 응답 모델""" + code: str + message: str + data: List[DBProfileInfo] + class QueryExecutionRequest(BaseModel): """쿼리 실행 요청 모델""" user_db_id: str @@ -53,21 +71,25 @@ 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]: - """사용 가능한 데이터베이스 목록을 가져옵니다.""" + async def get_db_profiles(self) -> List[DBProfileInfo]: + """모든 DBMS 프로필 정보를 가져옵니다.""" try: client = await self._get_client() response = await client.get( - f"{self.base_url}/api/v1/databases", + f"{self.base_url}/api/user/db/find/all", 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 + + # 응답 구조 검증 + if data.get("code") != "2102": + logger.warning(f"Unexpected response code: {data.get('code')}") + + profiles = [DBProfileInfo(**profile) for profile in data.get("data", [])] + logger.info(f"Successfully fetched {len(profiles)} DB profiles") + return profiles except httpx.HTTPStatusError as e: logger.error(f"HTTP error occurred: {e.response.status_code} - {e.response.text}") @@ -78,6 +100,62 @@ async def get_available_databases(self) -> List[DatabaseInfo]: except Exception as e: logger.error(f"Unexpected error: {e}") raise + + async def get_db_annotations(self, db_profile_id: str) -> Dict[str, Any]: + """특정 DBMS의 어노테이션을 조회합니다.""" + try: + client = await self._get_client() + response = await client.get( + f"{self.base_url}/api/annotations/find/db/{db_profile_id}", + headers=self.headers + ) + response.raise_for_status() + + data = response.json() + logger.info(f"Successfully fetched annotations for DB profile: {db_profile_id}") + return data + + except httpx.HTTPStatusError as e: + if e.response.status_code == 404: + # 404는 어노테이션이 없는 정상적인 상황 + logger.info(f"No annotations found for DB profile {db_profile_id}: {e.response.text}") + return {"code": "4401", "message": "어노테이션이 없습니다", "data": []} + else: + 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_available_databases(self) -> List[DatabaseInfo]: + """ + [DEPRECATED] 사용 가능한 데이터베이스 목록을 가져옵니다. + 대신 get_db_profiles()를 사용하세요. + """ + logger.warning("get_available_databases()는 deprecated입니다. get_db_profiles()를 사용하세요.") + + # DBMS 프로필 기반으로 DatabaseInfo 형태로 변환하여 호환성 유지 + try: + profiles = await self.get_db_profiles() + databases = [] + + for profile in profiles: + db_info = DatabaseInfo( + connection_name=f"{profile.type}_{profile.host}_{profile.port}", + database_name=profile.view_name or f"{profile.type}_db", + description=f"{profile.type} 데이터베이스 ({profile.host}:{profile.port})" + ) + databases.append(db_info) + + logger.info(f"Successfully converted {len(databases)} DB profiles to DatabaseInfo") + return databases + + except Exception as e: + logger.error(f"Failed to convert DB profiles: {e}") + raise # TODO: DB 스키마 조회 API 필요 async def get_database_schema(self, database_name: str) -> str: """특정 데이터베이스의 스키마 정보를 가져옵니다.""" diff --git a/src/services/database/database_service.py b/src/services/database/database_service.py index 40d3f14..752f8e3 100644 --- a/src/services/database/database_service.py +++ b/src/services/database/database_service.py @@ -1,19 +1,28 @@ # 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 +from typing import List, Optional, Dict, Any +from core.clients.api_client import APIClient, DatabaseInfo, DBProfileInfo, get_api_client import logging logger = logging.getLogger(__name__) class DatabaseService: - """데이터베이스 관련 비즈니스 로직을 담당하는 서비스 클래스""" + """ + 데이터베이스 관련 비즈니스 로직을 담당하는 서비스 클래스 + 지연 초기화를 지원하여 BE 서버가 늦게 시작되어도 작동합니다. + """ def __init__(self, api_client: APIClient = None): self.api_client = api_client + self._cached_db_profiles: Optional[List[DBProfileInfo]] = None + self._cached_annotations: Dict[str, Dict[str, Any]] = {} + # 호환성을 위해 유지하지만 더 이상 사용하지 않음 self._cached_databases: Optional[List[DatabaseInfo]] = None self._cached_schemas: Dict[str, str] = {} + # 지연 초기화 관련 플래그 + self._connection_attempted: bool = False + self._connection_failed: bool = False async def _get_api_client(self) -> APIClient: """API 클라이언트를 가져옵니다.""" @@ -22,14 +31,26 @@ async def _get_api_client(self) -> APIClient: return self.api_client async def get_available_databases(self) -> List[DatabaseInfo]: - """사용 가능한 데이터베이스 목록을 가져옵니다.""" + """ + [DEPRECATED] 사용 가능한 데이터베이스 목록을 가져옵니다. + 대신 get_databases_with_annotations()를 사용하세요. + """ + logger.warning("get_available_databases()는 deprecated입니다. get_databases_with_annotations()를 사용하세요.") + + # DBMS 프로필 기반으로 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") + profiles = await self.get_db_profiles() + databases = [] + + for profile in profiles: + db_info = DatabaseInfo( + connection_name=f"{profile.type}_{profile.host}_{profile.port}", + database_name=profile.view_name or f"{profile.type}_db", + description=f"{profile.type} 데이터베이스 ({profile.host}:{profile.port})" + ) + databases.append(db_info) - return self._cached_databases + return databases except Exception as e: logger.error(f"Failed to fetch databases: {e}") @@ -93,51 +114,120 @@ async def execute_query(self, sql_query: str, database_name: str = None, user_db 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 get_db_profiles(self) -> List[DBProfileInfo]: + """ + 모든 DBMS 프로필 정보를 가져옵니다. + 지연 초기화를 통해 BE 서버 연결이 실패해도 재시도합니다. + """ + if self._cached_db_profiles is None: + # 이전에 연결을 시도했고 실패했다면 재시도 + if self._connection_failed: + logger.info("🔄 DB 프로필 조회 재시도 중...") + self._connection_failed = False + self._connection_attempted = False + + try: + self._connection_attempted = True + api_client = await self._get_api_client() + self._cached_db_profiles = await api_client.get_db_profiles() + self._connection_failed = False + logger.info(f"✅ DB 프로필 조회 성공: {len(self._cached_db_profiles)}개") + + except Exception as e: + self._connection_failed = True + logger.error(f"❌ DB 프로필 조회 실패: {e}") + raise RuntimeError(f"DB 프로필 목록을 가져올 수 없습니다. 백엔드 서버가 실행 중인지 확인해주세요: {e}") + + return self._cached_db_profiles + + async def get_db_annotations(self, db_profile_id: str) -> Dict[str, Any]: + """특정 DBMS의 어노테이션을 조회합니다.""" + try: + if db_profile_id not in self._cached_annotations: + api_client = await self._get_api_client() + annotations = await api_client.get_db_annotations(db_profile_id) + self._cached_annotations[db_profile_id] = annotations + + if annotations.get("code") == "4401": + logger.info(f"No annotations available for DB profile: {db_profile_id}") + else: + logger.info(f"Cached annotations for DB profile: {db_profile_id}") + + return self._cached_annotations[db_profile_id] + + except Exception as e: + logger.error(f"Failed to fetch annotations for {db_profile_id}: {e}") + # 어노테이션이 없어도 기본 정보는 반환하도록 변경 + return {"code": "4401", "message": "어노테이션이 없습니다", "data": []} + + async def get_databases_with_annotations(self) -> List[Dict[str, Any]]: + """DB 프로필과 어노테이션을 함께 조회합니다.""" + try: + profiles = await self.get_db_profiles() + result = [] + + for profile in profiles: + annotations = await self.get_db_annotations(profile.id) + db_info = { + "profile": profile.model_dump(), + "annotations": annotations, + "display_name": profile.view_name or f"{profile.type}_{profile.host}_{profile.port}", + "description": self._generate_db_description(profile, annotations) + } + result.append(db_info) + + return result + + except Exception as e: + logger.error(f"Failed to get databases with annotations: {e}") + raise RuntimeError(f"어노테이션이 포함된 데이터베이스 목록을 가져올 수 없습니다: {e}") + + def _generate_db_description(self, profile: DBProfileInfo, annotations: Dict[str, Any]) -> str: + """DB 프로필과 어노테이션을 기반으로 설명을 생성합니다.""" + try: + # 기본 설명 + base_desc = f"{profile.type} 데이터베이스" + + if profile.view_name: + base_desc += f" ({profile.view_name})" + else: + base_desc += f" ({profile.host}:{profile.port})" + + # 어노테이션 정보 확인 + if annotations and annotations.get("code") != "4401" and "data" in annotations: + # 실제 어노테이션이 있는 경우 + base_desc += " - 어노테이션 정보 포함" + + return base_desc + + except Exception as e: + logger.warning(f"Failed to generate description: {e}") + return f"{profile.type} 데이터베이스" + async def refresh_cache(self): """캐시를 새로고침합니다.""" + self._cached_db_profiles = None + self._cached_annotations.clear() + # 호환성을 위해 유지 self._cached_databases = None self._cached_schemas.clear() + # 지연 초기화 플래그 리셋 + self._connection_attempted = False + self._connection_failed = False logger.info("Database cache refreshed") async def clear_cache(self): """캐시를 클리어합니다.""" + self._cached_db_profiles = None + self._cached_annotations.clear() + # 호환성을 위해 유지 self._cached_databases = None self._cached_schemas.clear() + # 지연 초기화 플래그 리셋 + self._connection_attempted = False + self._connection_failed = False logger.info("Database cache cleared") async def health_check(self) -> bool: From af899b50638b59601bdc30bac8b1d9f6043d1ca0 Mon Sep 17 00:00:00 2001 From: CheckerBoard Date: Wed, 20 Aug 2025 00:40:45 +0900 Subject: [PATCH 80/80] Update README.md --- README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index e6c9c90..75f36c7 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # AI 모델 프로젝트 -이 프로젝트는 ...을 위한 AI 모델을 개발합니다. +이 프로젝트는 DB 어노테이션과 SQL 챗봇을 위한 AI 서버를 개발합니다. --- @@ -39,7 +39,7 @@ rm -rf build dist # 실행 파일 빌드 - pyinstaller --clean --onefile --name ai src/main.py + pyinstaller --clean --onefile --add-data "src/prompts:prompts" --name ai src/main.py ``` --- @@ -82,5 +82,5 @@ GitHub에서 새로운 태그를 발행하면 파이프라인이 자동으로 ./dist/ai # 다른 터미널에서 헬스체크 요청 -curl http://localhost:<할당된 포트>/health -``` \ No newline at end of file +curl http://localhost:<할당된 포트>/api/v1/health +```