# app/services/chat_service.py """ HealthSync AI 챗봇 상담 서비스 """ from typing import Dict, Any, List from datetime import datetime from app.services.base_service import BaseService from app.services.health_service import HealthService from app.utils.claude_client import ClaudeClient from app.config.prompts import get_chat_consultation_prompt from app.dto.response.chat_response import ChatResponse from app.dto.response.chat_history_response import ChatHistoryResponse, ChatHistoryItem from app.repositories.chat_repository import ChatRepository class ChatService(BaseService): """챗봇 상담 비즈니스 로직 서비스""" def __init__(self): super().__init__() self.health_service = HealthService() self.claude_client = ClaudeClient() self.chat_repository = ChatRepository() async def get_health_consultation(self, user_id: int, message: str) -> ChatResponse: """건강 상담 챗봇 응답 생성 및 저장 (consultation 타입 단일 레코드 업데이트 방식)""" consultation_message_id = None try: self.log_operation("get_health_consultation_start", user_id=user_id, message_length=len(message)) # 1. consultation 타입으로 질문과 "응답 생성중" 메시지를 한 번에 저장 consultation_message_id = await self.chat_repository.save_chat_message( user_id=user_id, message_type="consultation", message_content=message, response_content="💭 응답을 생성하고 있습니다..." ) # 2. 사용자 건강 데이터 조회 combined_data = await self.health_service.get_combined_user_health_data(user_id) user_data = combined_data["user_info"] health_data = combined_data["health_data"] # 3. 상담 프롬프트 생성 prompt = await self._build_consultation_prompt(user_data, health_data, message) # 4. Claude API 호출 claude_response = await self.claude_client.call_claude_api(prompt) # 5. 응답 정제 ai_response = claude_response.strip() # 6. "응답 생성중" 메시지를 실제 응답으로 업데이트 await self.chat_repository.update_chat_message_response( message_id=consultation_message_id, response_content=ai_response ) # 7. 최종 응답 생성 response = ChatResponse( response=ai_response, timestamp=datetime.now() ) self.log_operation("get_health_consultation_success", user_id=user_id, consultation_id=consultation_message_id, response_length=len(ai_response)) return response except Exception as e: # 오류 발생 시 "응답 생성중" 메시지를 오류 메시지로 업데이트 if consultation_message_id: try: await self.chat_repository.update_chat_message_response( message_id=consultation_message_id, response_content="❌ 죄송합니다. 응답 생성 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요." ) except: pass # 업데이트 실패 시에도 원본 오류를 유지 self.logger.error(f"건강 상담 처리 실패 - user_id: {user_id}, error: {str(e)}") raise Exception(f"건강 상담 처리 실패: {str(e)}") async def get_chat_history(self, user_id: int) -> ChatHistoryResponse: """사용자 채팅 이력 조회 (NULL 값 그대로 반환)""" try: self.log_operation("get_chat_history_start", user_id=user_id) # 1. 데이터베이스에서 채팅 이력 조회 chat_records = await self.chat_repository.get_chat_history_by_user_id(user_id) # 2. DTO로 변환 (NULL 값 처리) chat_history_items = [] for record in chat_records: chat_item = ChatHistoryItem( message_id=record["message_id"], message_type=record["message_type"], message_content=record.get("message_content"), response_content=record["response_content"] or "", created_at=record["created_at"] ) chat_history_items.append(chat_item) # 3. 응답 생성 response = ChatHistoryResponse( chat_history=chat_history_items, total_count=len(chat_history_items) ) self.log_operation("get_chat_history_success", user_id=user_id, total_count=len(chat_history_items), null_message_count=sum(1 for item in chat_history_items if item.message_content is None)) return response except Exception as e: self.logger.error(f"채팅 이력 조회 실패 - user_id: {user_id}, error: {str(e)}") raise Exception(f"채팅 이력 조회 실패: {str(e)}") async def _build_consultation_prompt(self, user_data: Dict[str, Any], health_data: Dict[str, Any], message: str) -> str: """사용자 데이터와 질문을 기반으로 상담 프롬프트 생성""" try: prompt_template = get_chat_consultation_prompt() # BMI 계산 bmi = health_data.get("bmi", 0) if not bmi and health_data.get("height") and health_data.get("weight"): height_m = health_data["height"] / 100 bmi = round(health_data["weight"] / (height_m ** 2), 1) # 프롬프트에 데이터 매핑 formatted_prompt = prompt_template.format( # 사용자 질문 user_question=message, # 사용자 기본 정보 occupation=user_data.get("occupation", "정보 없음"), name=user_data.get("name", "정보 없음"), age=user_data.get("age", health_data.get("age", "정보 없음")), # 신체 정보 height=health_data.get("height", "정보 없음"), weight=health_data.get("weight", "정보 없음"), bmi=bmi if bmi else "정보 없음", waist_circumference=health_data.get("waist_circumference", "정보 없음"), # 혈압 및 혈당 systolic_bp=health_data.get("systolic_bp", "정보 없음"), diastolic_bp=health_data.get("diastolic_bp", "정보 없음"), fasting_glucose=health_data.get("fasting_glucose", "정보 없음"), # 콜레스테롤 total_cholesterol=health_data.get("total_cholesterol", "정보 없음"), hdl_cholesterol=health_data.get("hdl_cholesterol", "정보 없음"), ldl_cholesterol=health_data.get("ldl_cholesterol", "정보 없음"), triglyceride=health_data.get("triglyceride", "정보 없음"), # 기타 혈액 검사 hemoglobin=health_data.get("hemoglobin", "정보 없음"), serum_creatinine=health_data.get("serum_creatinine", "정보 없음"), ast=health_data.get("ast", "정보 없음"), alt=health_data.get("alt", "정보 없음"), gamma_gtp=health_data.get("gamma_gtp", "정보 없음"), urine_protein=health_data.get("urine_protein", "정보 없음"), # 감각기관 visual_acuity_left=health_data.get("visual_acuity_left", "정보 없음"), visual_acuity_right=health_data.get("visual_acuity_right", "정보 없음"), hearing_left=health_data.get("hearing_left", "정보 없음"), hearing_right=health_data.get("hearing_right", "정보 없음"), # 생활습관 smoking_status=self._convert_smoking_status(health_data.get("smoking_status")), drinking_status=self._convert_drinking_status(health_data.get("drinking_status")) ) self.log_operation("build_consultation_prompt", user_id="N/A", prompt_length=len(formatted_prompt)) return formatted_prompt except Exception as e: self.logger.error(f"상담 프롬프트 생성 실패: {str(e)}") raise Exception(f"상담 프롬프트 생성 실패: {str(e)}") def _convert_smoking_status(self, status: int) -> str: """흡연 상태 코드를 텍스트로 변환""" smoking_map = { 0: "비흡연", 1: "과거 흡연", 2: "현재 흡연" } return smoking_map.get(status, "정보 없음") def _convert_drinking_status(self, status: int) -> str: """음주 상태 코드를 텍스트로 변환""" drinking_map = { 0: "비음주", 1: "음주" } return drinking_map.get(status, "정보 없음")