diff --git a/chatbot/config.py b/chatbot/config.py index b68af22..5e8a2ca 100644 --- a/chatbot/config.py +++ b/chatbot/config.py @@ -16,6 +16,10 @@ def __init__(self): self.anthropic_api_key = os.environ.get('ANTHROPIC_API_KEY') self.gcp_ssm_param_name = os.environ.get('GCP_SSM_PARAM_NAME') + # Gemma 3 설정 (Hugging Face) + self.hf_endpoint_url = os.environ.get("HF_ENDPOINT_URL", "") + self.hf_api_token = os.environ.get("HF_API_TOKEN", "") + # SQS 설정 self.cbt_log_sqs_url = os.environ.get('CBT_LOG_SQS_URL') self.diary_to_chatbot_sqs_url = os.environ.get('DIARY_TO_CHATBOT_SQS_URL') diff --git a/chatbot/controller/dev_controller.py b/chatbot/controller/dev_controller.py new file mode 100644 index 0000000..64bf95c --- /dev/null +++ b/chatbot/controller/dev_controller.py @@ -0,0 +1,118 @@ +# chatbot/controller/dev_controller.py +import json +import boto3 +import logging +from datetime import datetime +from fastapi import APIRouter, Depends +from exception import AppError +from config import config +from schema.test import MindDiaryTestRequest +from schema.reframing import ReframingRequest, ReframingResponse +from service.llm_service import LLMService, get_llm_service +from prompts.reframing import REFRAMING_PROMPT_TEMPLATE + +logger = logging.getLogger() +router = APIRouter(tags=["Dev / Experiment"]) + +@router.post( + "/chatbot/dev/sqs/mind-diary", + summary="[개발용] 마음일기 분석 완료 이벤트 SQS 전송 테스트", + description=""" + 마음일기 서비스(caring-back)에서 분석이 완료된 상황을 시뮬레이션하여 SQS 메시지를 강제로 전송합니다. + """ +) +def trigger_mind_diary_event(request: MindDiaryTestRequest): + # 1. 전송할 SQS URL 확인 + sqs_url = config.diary_to_chatbot_sqs_url + if not sqs_url: + sqs_url = config.cbt_log_sqs_url # Fallback + + if not sqs_url: + raise AppError( + status_code=500, + message="SQS URL이 설정되지 않았습니다." + ) + + try: + # 2. SQS 메시지 본문 구성 + message_body = { + "source": "mind-diary", + "event": "analysis_completed", + "user_id": request.user_id, + "voice_id": 999999, # 테스트용 더미 ID + "user_name": request.user_name, + "question": request.question, + "content": request.content, + "recorded_at": request.recorded_at or datetime.now().isoformat(), + "timestamp": datetime.now().isoformat(), + "emotion": request.emotion.model_dump() + } + + # 3. SQS 전송 + sqs_client = boto3.client('sqs', region_name='ap-northeast-2') + response = sqs_client.send_message( + QueueUrl=sqs_url, + MessageBody=json.dumps(message_body, ensure_ascii=False) + ) + + return { + "success": True, + "message": "Simulated Mind Diary event sent to SQS", + "message_id": response.get("MessageId"), + "payload_preview": message_body + } + + except Exception as e: + raise AppError( + status_code=500, + message="SQS 메시지 전송에 실패했습니다.", + detail=str(e) + ) + +@router.post( + "/chatbot/dev/reframing", + response_model=ReframingResponse, + summary="[DEV] Gemma 2 리프레이밍 실험", + description=""" + 운영 중인 `/chatbot/reframing` API와 **동일한 입력(Request)과 출력(Response)** 규격을 가집니다. + 내부적으로 DB를 조회하지 않고, **Gemma 2 (Hugging Face)** 모델을 호출하여 답변을 생성합니다. + + - **목적**: 기존 모델(Claude/Gemini)과 Gemma 3의 상담 성능 직접 비교 (A/B 테스트) + - **입력**: ReframingRequest (`user_input` 필수) + - **출력**: ReframingResponse (공감, 분석, 질문, 대안 등) + - **제약**: Dev 모드이므로 이전 대화 내역(History)은 반영되지 않습니다. + """ +) +def dev_reframing_gemma( + request: ReframingRequest, + service: LLMService = Depends(get_llm_service) +): + full_prompt = REFRAMING_PROMPT_TEMPLATE.format( + history_text="(없음. 대화 시작)", + user_input=request.user_input + ) + + raw_response = service.get_gemma_response(full_prompt) + + try: + # 마크다운 코드블록 제거 (```json ... ```) + cleaned_json = raw_response.replace("```json", "").replace("```", "").strip() + data = json.loads(cleaned_json) + + return ReframingResponse( + empathy=data.get("empathy", "공감 내용을 불러오지 못했습니다."), + detected_distortion=data.get("detected_distortion", "분석 불가"), + analysis=data.get("analysis", "분석 내용을 생성 중 오류가 발생했습니다."), + socratic_question=data.get("socratic_question", "질문을 생성하지 못했습니다."), + alternative_thought=data.get("alternative_thought", "대안을 찾지 못했습니다."), + emotion=data.get("top_emotion", None) # 감정 필드 추가 + ) + except json.JSONDecodeError: + logger.error(f"JSON 파싱 실패. 원본 응답: {raw_response}") + return ReframingResponse( + empathy=f"[JSON 파싱 실패] 모델 응답: {raw_response}", + detected_distortion="에러", + analysis="에러", + socratic_question="에러", + alternative_thought="에러" + ) diff --git a/chatbot/controller/test_controller.py b/chatbot/controller/test_controller.py deleted file mode 100644 index fa2ca65..0000000 --- a/chatbot/controller/test_controller.py +++ /dev/null @@ -1,72 +0,0 @@ -# chatbot/controller/test_controller.py -import json -import boto3 -from datetime import datetime -from fastapi import APIRouter -from exception import AppError -from schema.test import MindDiaryTestRequest -from config import config - -router = APIRouter(tags=["Dev Test"]) - -@router.post( - "/chatbot/test/sqs/mind-diary", - summary="[개발용] 마음일기 분석 완료 이벤트 SQS 전송 테스트", - description=""" - 마음일기 서비스(caring-back)에서 분석이 완료된 상황을 시뮬레이션하여 SQS 메시지를 강제로 전송합니다. - - - **목적**: 챗봇의 '선제적 대화(Proactive Message)' 생성 로직 검증 - - **동작**: 입력받은 데이터를 SQS(`diary-to-chatbot-sqs`)로 전송 -> 챗봇 Worker Lambda 트리거 -> DB에 대화 생성 - - **테스트 확인**: 요청(request)시에 보낸 user_id를 이용해서 리스트를 검색해보세요. ai 분석이 끝나는 대로 새로운 대화가 확인 가능합니다. - - **이후 대화**: 처음 대화 이후에 이어나가고 싶으면 기존 CBT 질문 API를 활용 해주세요. - """ -) -def trigger_mind_diary_event(request: MindDiaryTestRequest): - # 1. 전송할 SQS URL 확인 (Config 객체 사용) - sqs_url = config.diary_to_chatbot_sqs_url - - # [Fallback] 전용 큐 설정이 없다면 기존 로그 큐 사용 (테스트 편의성) - if not sqs_url: - sqs_url = config.cbt_log_sqs_url - - if not sqs_url: - raise AppError( - status_code=500, - message="SQS URL이 설정되지 않았습니다." - ) - - try: - # 2. SQS 메시지 본문 구성 - message_body = { - "source": "mind-diary", - "event": "analysis_completed", - "user_id": request.user_id, - "voice_id": 999999, # 테스트용 더미 ID - "user_name": request.user_name, - "question": request.question, - "content": request.content, - "recorded_at": request.recorded_at or datetime.now().isoformat(), - "timestamp": datetime.now().isoformat(), - "emotion": request.emotion.model_dump() - } - - # 3. SQS 전송 - sqs_client = boto3.client('sqs', region_name='ap-northeast-2') - response = sqs_client.send_message( - QueueUrl=sqs_url, - MessageBody=json.dumps(message_body, ensure_ascii=False) - ) - - return { - "success": True, - "message": "Simulated Mind Diary event sent to SQS", - "message_id": response.get("MessageId"), - "payload_preview": message_body - } - - except Exception as e: - raise AppError( - status_code=500, - message="SQS 메시지 전송에 실패했습니다.", - detail=str(e) - ) diff --git a/chatbot/main.py b/chatbot/main.py index 97642c0..28fecff 100644 --- a/chatbot/main.py +++ b/chatbot/main.py @@ -3,7 +3,7 @@ from starlette.exceptions import HTTPException as StarletteHTTPException from fastapi.exceptions import RequestValidationError -from controller import chat_controller, search_controller, report_controller, test_controller +from controller import chat_controller, search_controller, report_controller, dev_controller from exception import ( AppError, @@ -44,7 +44,7 @@ app.include_router(search_controller.router) app.include_router(chat_controller.router) app.include_router(report_controller.router) -app.include_router(test_controller.router) +app.include_router(dev_controller.router) @app.get("/chatbot/health", tags=["Health Check"]) def health_check(): diff --git a/chatbot/prompts/mind_diary.py b/chatbot/prompts/mind_diary.py index 06a46f1..002d05c 100644 --- a/chatbot/prompts/mind_diary.py +++ b/chatbot/prompts/mind_diary.py @@ -1,7 +1,7 @@ # chatbot/prompts/mind_diary.py MIND_DIARY_PROMPT_TEMPLATE = """ -당신은 전문 심리상담사이자 CBT(인지행동치료) 전문가입니다. +당신은 전문 심리상담사이자 CBT(인지행동치료) 전문가 '도란이'입니다. 사용자 '{user_name}'님이 작성한 '마음일기'를 읽고, 먼저 다가가서 대화를 시작해야 합니다. [마음일기 정보] diff --git a/chatbot/requirements.txt b/chatbot/requirements.txt index 80a96bb..10e1d31 100644 --- a/chatbot/requirements.txt +++ b/chatbot/requirements.txt @@ -4,6 +4,7 @@ pydantic fastapi uvicorn mangum +openai>=1.0.0 # boto3 제거 (AWS 런타임 제공) # google-cloud-aiplatform 제거 (Layer로 제공) # google-auth 제거 (Layer로 제공) diff --git a/chatbot/service/llm_service.py b/chatbot/service/llm_service.py index a1ec6ee..4c5d9e2 100644 --- a/chatbot/service/llm_service.py +++ b/chatbot/service/llm_service.py @@ -1,10 +1,9 @@ -# chatbot/service/llm_service.py import json import boto3 import logging from botocore.exceptions import ClientError from functools import lru_cache - +from openai import OpenAI, OpenAIError try: from config import config except ImportError: @@ -25,6 +24,10 @@ class LLMService: MODEL_ID_BEDROCK_EMBEDDING = 'amazon.titan-embed-text-v2:0' MODEL_ID_GEMINI = "gemini-2.5-pro" + # vLLM에 로드된 모델명 (서버 로그나 curl /v1/models로 확인된 ID 사용) + # 만약 이름을 모르면 "/workspace/" 혹은 "default" 등을 시도 + MODEL_ID_GEMMA = "0xMori/gemma-2-9b-safori-cbt-merged" + def __init__(self): # AWS Client 초기화 self.bedrock_runtime = boto3.client(service_name='bedrock-runtime', region_name='ap-northeast-2') @@ -32,6 +35,9 @@ def __init__(self): # Vertex AI (Gemini) 초기화 self.gemini_pro_model = self._init_gemini() + # Hugging Face (vLLM) 클라이언트 초기화 + self.hf_client = self._init_hf_client() + def _init_gemini(self): if not vertexai: logger.info("Gemini 라이브러리 없음") @@ -54,6 +60,62 @@ def _init_gemini(self): logger.error(f"Vertex AI 초기화 실패: {e}") return None + def _init_hf_client(self): + """ + OpenAI 호환 클라이언트를 초기화합니다 (vLLM용) + """ + if not config.hf_endpoint_url or not config.hf_api_token: + logger.warning("HF Endpoint URL 또는 Token이 설정되지 않아 vLLM 클라이언트를 초기화하지 않습니다.") + return None + + try: + # vLLM/OpenAI 호환 주소 처리: 끝에 /v1 붙이기 + base_url = f"{config.hf_endpoint_url.rstrip('/')}/v1" + + client = OpenAI( + base_url=base_url, + api_key=config.hf_api_token + ) + logger.info("HF vLLM(OpenAI) 클라이언트 초기화 성공") + return client + except Exception as e: + logger.error(f"HF 클라이언트 초기화 실패: {e}") + return None + + def get_gemma_response(self, prompt: str, max_tokens: int = 2048) -> str: + """ + vLLM에 배포된 Gemma 3 모델을 호출합니다. (OpenAI SDK 사용) + """ + + if not self.hf_client: + error_msg = "오류: vLLM 클라이언트가 초기화되지 않았습니다. 설정을 확인하세요." + logger.error(error_msg) + return json.dumps({"empathy": error_msg}, ensure_ascii=False) + + messages = [ + {"role": "user", "content": prompt} + ] + + try: + response = self.hf_client.chat.completions.create( + model=self.MODEL_ID_GEMMA, + messages=messages, + max_tokens=max_tokens, + temperature=0.7, + top_p=0.9 + ) + + result_text = response.choices[0].message.content + return result_text + + except OpenAIError as e: + logger.error(f"vLLM 호출 오류 (OpenAI Error): {e}") + return json.dumps({"empathy": f"AI 모델 오류: {str(e)}"}, ensure_ascii=False) + + except Exception as e: + logger.error(f"Gemma 연결 알 수 없는 오류: {e}") + return json.dumps({"empathy": f"시스템 오류: {str(e)}"}, ensure_ascii=False) + def get_embedding(self, text: str) -> list[float]: body = json.dumps({"inputText": text}) try: diff --git a/chatbot/service/worker_service.py b/chatbot/service/worker_service.py index d6ed4d3..7e69da5 100644 --- a/chatbot/service/worker_service.py +++ b/chatbot/service/worker_service.py @@ -185,7 +185,7 @@ def _handle_mind_diary_event(payload: dict, repo: ChatRepository, llm) -> bool: repo.log_cbt_session( user_id=user_id, session_id=new_session_id, - user_input="(마음일기 기반 선제 대화)", # 식별용 마커 + user_input=content, # 식별용 마커 bot_response=final_bot_response, embedding=[0.0] * 1024, s3_url=s3_url diff --git a/chatbot/test/services/test_worker_service.py b/chatbot/test/services/test_worker_service.py index 878dbce..508cc11 100644 --- a/chatbot/test/services/test_worker_service.py +++ b/chatbot/test/services/test_worker_service.py @@ -126,7 +126,7 @@ def test_process_sqs_mind_diary_event(mock_get_db_conn, mock_get_llm, MockChatRe # 저장된 내용 검증 _, kwargs = mock_repo_instance.log_cbt_session.call_args assert kwargs["user_id"] == "test_user" - assert kwargs["user_input"] == "(마음일기 기반 선제 대화)" + assert kwargs["user_input"] == "열심히 공부했는데 시험을 망쳐서 너무 슬퍼." # 봇 응답의 empathy가 LLM 결과와 일치하는지 확인 assert kwargs["bot_response"]["empathy"] == "시험 때문에 많이 속상하셨겠어요."