This commit is contained in:
@@ -0,0 +1,33 @@
|
||||
"""
|
||||
HealthSync AI 기본 서비스 클래스
|
||||
"""
|
||||
from abc import ABC
|
||||
from app.config.settings import settings
|
||||
import logging
|
||||
import time
|
||||
from datetime import datetime
|
||||
|
||||
class BaseService(ABC):
|
||||
"""기본 서비스 추상 클래스"""
|
||||
|
||||
def __init__(self):
|
||||
self.settings = settings
|
||||
self.logger = logging.getLogger(self.__class__.__name__)
|
||||
self._start_time = time.time()
|
||||
|
||||
def get_uptime(self) -> float:
|
||||
"""서비스 가동 시간 반환 (초)"""
|
||||
return time.time() - self._start_time
|
||||
|
||||
def log_operation(self, operation: str, user_id: int = None, **kwargs):
|
||||
"""작업 로그 기록"""
|
||||
log_data = {
|
||||
"operation": operation,
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"service": self.__class__.__name__
|
||||
}
|
||||
if user_id:
|
||||
log_data["user_id"] = user_id
|
||||
log_data.update(kwargs)
|
||||
|
||||
self.logger.info(f"Operation: {operation}", extra=log_data)
|
||||
@@ -0,0 +1,203 @@
|
||||
# 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, "정보 없음")
|
||||
@@ -0,0 +1,275 @@
|
||||
# app/services/health_service.py
|
||||
"""
|
||||
HealthSync AI 건강 데이터 서비스
|
||||
"""
|
||||
import asyncio
|
||||
from typing import Dict, Any, Optional
|
||||
from app.services.base_service import BaseService
|
||||
from app.utils.claude_client import ClaudeClient
|
||||
from app.config.prompts import get_health_diagnosis_prompt
|
||||
from app.repositories.health_repository import HealthRepository
|
||||
from app.exceptions import (
|
||||
UserNotFoundException,
|
||||
HealthDataNotFoundException,
|
||||
DatabaseException,
|
||||
ClaudeAPIException
|
||||
)
|
||||
|
||||
|
||||
class HealthService(BaseService):
|
||||
"""건강 데이터 조회 서비스"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.claude_client = ClaudeClient()
|
||||
self.health_repository = HealthRepository()
|
||||
|
||||
async def get_latest_health_checkup(self, user_id: int) -> Dict[str, Any]:
|
||||
"""사용자의 최신 건강검진 데이터 조회"""
|
||||
try:
|
||||
self.log_operation("get_latest_health_checkup", user_id=user_id)
|
||||
|
||||
# 실제 DB에서 데이터 조회
|
||||
health_data = await self.health_repository.get_latest_health_checkup_by_user_id(user_id)
|
||||
|
||||
if not health_data:
|
||||
raise HealthDataNotFoundException(user_id)
|
||||
|
||||
# BMI 계산 (DB에 없는 경우)
|
||||
if not health_data.get("bmi") and health_data.get("height") and health_data.get("weight"):
|
||||
height_m = health_data["height"] / 100
|
||||
health_data["bmi"] = round(health_data["weight"] / (height_m ** 2), 1)
|
||||
|
||||
self.log_operation("get_latest_health_checkup_success", user_id=user_id,
|
||||
checkup_year=health_data.get("reference_year"))
|
||||
|
||||
return health_data
|
||||
|
||||
except (UserNotFoundException, HealthDataNotFoundException):
|
||||
# 사용자 정의 예외는 그대로 전파
|
||||
raise
|
||||
except Exception as e:
|
||||
self.logger.error(f"건강검진 데이터 조회 실패 - user_id: {user_id}, error: {str(e)}")
|
||||
raise DatabaseException(f"건강검진 데이터 조회 중 오류가 발생했습니다: {str(e)}")
|
||||
|
||||
async def get_user_basic_info(self, user_id: int) -> Dict[str, Any]:
|
||||
"""사용자 기본 정보 조회 (직업, 나이 등)"""
|
||||
try:
|
||||
self.log_operation("get_user_basic_info", user_id=user_id)
|
||||
|
||||
# 실제 DB에서 데이터 조회
|
||||
user_data = await self.health_repository.get_user_basic_info_by_id(user_id)
|
||||
|
||||
if not user_data:
|
||||
raise UserNotFoundException(user_id)
|
||||
|
||||
# 생년월일에서 나이 계산 (DB 쿼리에서 계산하지만 추가 검증)
|
||||
if not user_data.get("age") and user_data.get("birth_date"):
|
||||
from datetime import datetime
|
||||
birth_date = user_data["birth_date"]
|
||||
if isinstance(birth_date, str):
|
||||
birth_date = datetime.strptime(birth_date, "%Y-%m-%d").date()
|
||||
today = datetime.now().date()
|
||||
user_data["age"] = today.year - birth_date.year - (
|
||||
(today.month, today.day) < (birth_date.month, birth_date.day))
|
||||
|
||||
self.log_operation("get_user_basic_info_success", user_id=user_id,
|
||||
occupation=user_data.get("occupation"))
|
||||
|
||||
return user_data
|
||||
|
||||
except UserNotFoundException:
|
||||
# 사용자 정의 예외는 그대로 전파
|
||||
raise
|
||||
except Exception as e:
|
||||
self.logger.error(f"사용자 기본정보 조회 실패 - user_id: {user_id}, error: {str(e)}")
|
||||
raise DatabaseException(f"사용자 정보 조회 중 오류가 발생했습니다: {str(e)}")
|
||||
|
||||
async def get_combined_user_health_data(self, user_id: int) -> Dict[str, Any]:
|
||||
"""사용자 기본정보와 건강검진 데이터를 결합하여 반환"""
|
||||
try:
|
||||
# 병렬로 데이터 조회
|
||||
user_data, health_data = await asyncio.gather(
|
||||
self.get_user_basic_info(user_id),
|
||||
self.get_latest_health_checkup(user_id)
|
||||
)
|
||||
|
||||
# 데이터 결합
|
||||
combined_data = {
|
||||
"user_info": user_data,
|
||||
"health_data": health_data
|
||||
}
|
||||
|
||||
self.log_operation("get_combined_user_health_data", user_id=user_id,
|
||||
has_user_data=bool(user_data), has_health_data=bool(health_data))
|
||||
|
||||
return combined_data
|
||||
|
||||
except (UserNotFoundException, HealthDataNotFoundException, DatabaseException):
|
||||
# 사용자 정의 예외는 그대로 전파
|
||||
raise
|
||||
except Exception as e:
|
||||
self.logger.error(f"사용자 종합 데이터 조회 실패 - user_id: {user_id}, error: {str(e)}")
|
||||
raise DatabaseException(f"사용자 종합 데이터 조회 중 오류가 발생했습니다: {str(e)}")
|
||||
|
||||
async def get_three_sentence_diagnosis(self, user_id: int) -> str:
|
||||
"""사용자 건강검진 데이터 기반 3줄 요약 진단"""
|
||||
try:
|
||||
self.log_operation("get_three_sentence_diagnosis_start", user_id=user_id)
|
||||
|
||||
# 1. 사용자 데이터 조회
|
||||
combined_data = await self.get_combined_user_health_data(user_id)
|
||||
user_data = combined_data["user_info"]
|
||||
health_data = combined_data["health_data"]
|
||||
|
||||
# 2. 프롬프트 생성
|
||||
prompt = self._build_diagnosis_prompt(user_data, health_data)
|
||||
|
||||
# 3. Claude API 호출
|
||||
claude_response = await self.claude_client.call_claude_api(prompt)
|
||||
|
||||
# 4. 응답 정제 (앞뒤 공백 제거)
|
||||
diagnosis = claude_response.strip()
|
||||
|
||||
self.log_operation("get_three_sentence_diagnosis_success", user_id=user_id,
|
||||
diagnosis_length=len(diagnosis))
|
||||
|
||||
return diagnosis
|
||||
|
||||
except (UserNotFoundException, HealthDataNotFoundException, DatabaseException):
|
||||
# 사용자 정의 예외는 그대로 전파
|
||||
raise
|
||||
except Exception as e:
|
||||
# Claude API 오류
|
||||
self.logger.error(f"3줄 요약 진단 실패 - user_id: {user_id}, error: {str(e)}")
|
||||
raise ClaudeAPIException(f"건강 진단 분석 중 오류가 발생했습니다: {str(e)}")
|
||||
|
||||
async def get_combined_user_health_data(self, user_id: int) -> Dict[str, Any]:
|
||||
"""사용자 기본정보와 건강검진 데이터를 결합하여 반환"""
|
||||
try:
|
||||
# 병렬로 데이터 조회
|
||||
user_data, health_data = await asyncio.gather(
|
||||
self.get_user_basic_info(user_id),
|
||||
self.get_latest_health_checkup(user_id)
|
||||
)
|
||||
|
||||
# 데이터 결합
|
||||
combined_data = {
|
||||
"user_info": user_data,
|
||||
"health_data": health_data
|
||||
}
|
||||
|
||||
self.log_operation("get_combined_user_health_data", user_id=user_id,
|
||||
has_user_data=bool(user_data), has_health_data=bool(health_data))
|
||||
|
||||
return combined_data
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"사용자 종합 데이터 조회 실패 - user_id: {user_id}, error: {str(e)}")
|
||||
raise Exception(f"사용자 종합 데이터 조회 실패: {str(e)}")
|
||||
|
||||
async def get_three_sentence_diagnosis(self, user_id: int) -> str:
|
||||
"""사용자 건강검진 데이터 기반 3줄 요약 진단"""
|
||||
try:
|
||||
self.log_operation("get_three_sentence_diagnosis_start", user_id=user_id)
|
||||
|
||||
# 1. 사용자 데이터 조회
|
||||
combined_data = await self.get_combined_user_health_data(user_id)
|
||||
user_data = combined_data["user_info"]
|
||||
health_data = combined_data["health_data"]
|
||||
|
||||
# 2. 프롬프트 생성
|
||||
prompt = self._build_diagnosis_prompt(user_data, health_data)
|
||||
|
||||
# 3. Claude API 호출
|
||||
claude_response = await self.claude_client.call_claude_api(prompt)
|
||||
|
||||
# 4. 응답 정제 (앞뒤 공백 제거)
|
||||
diagnosis = claude_response.strip()
|
||||
|
||||
self.log_operation("get_three_sentence_diagnosis_success", user_id=user_id,
|
||||
diagnosis_length=len(diagnosis))
|
||||
|
||||
return diagnosis
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"3줄 요약 진단 실패 - user_id: {user_id}, error: {str(e)}")
|
||||
raise Exception(f"건강 진단 분석 실패: {str(e)}")
|
||||
|
||||
def _build_diagnosis_prompt(self, user_data: Dict[str, Any], health_data: Dict[str, Any]) -> str:
|
||||
"""사용자 데이터를 기반으로 진단 프롬프트 생성"""
|
||||
try:
|
||||
prompt_template = get_health_diagnosis_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(
|
||||
# 사용자 기본 정보
|
||||
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_diagnosis_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, "정보 없음")
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user