1338 lines
60 KiB
Python
1338 lines
60 KiB
Python
# app/services/mission_service.py
|
|
"""
|
|
HealthSync AI 미션 관련 서비스 (벡터 초기화 기능 추가)
|
|
"""
|
|
import time
|
|
from typing import Dict, Any, List, Optional
|
|
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.utils.vector_client import pinecone_client
|
|
from app.utils.redis_client import redis_client
|
|
from app.config.prompts import get_mission_recommendation_prompt, get_celebration_prompt
|
|
from app.dto.response.mission_response import MissionRecommendationResponse, RecommendedMission
|
|
from app.dto.response.celebration_response import CelebrationResponse
|
|
from app.dto.response.similar_mission_news_response import SimilarMissionNewsResponse, MissionNewsItem
|
|
from app.repositories.chat_repository import ChatRepository
|
|
from app.repositories.mission_repository import MissionRepository
|
|
from app.repositories.similar_mission_repository import SimilarMissionRepository
|
|
from app.exceptions import (
|
|
UserNotFoundException,
|
|
HealthDataNotFoundException,
|
|
DatabaseException,
|
|
ClaudeAPIException
|
|
)
|
|
|
|
|
|
class MissionService(BaseService):
|
|
"""미션 관련 비즈니스 로직 서비스 (벡터 초기화 기능 추가)"""
|
|
|
|
def __init__(self):
|
|
super().__init__()
|
|
self.health_service = HealthService()
|
|
self.claude_client = ClaudeClient()
|
|
self.mission_repository = MissionRepository()
|
|
self.chat_repository = ChatRepository()
|
|
self.similar_mission_repository = SimilarMissionRepository()
|
|
|
|
async def recommend_missions(self, user_id: int) -> MissionRecommendationResponse:
|
|
"""미션 추천 전체 프로세스 실행"""
|
|
try:
|
|
self.log_operation("recommend_missions_start", user_id=user_id)
|
|
|
|
# 1. 사용자 데이터 조회
|
|
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"]
|
|
|
|
# 2. 프롬프트 생성
|
|
prompt = await self._build_recommendation_prompt(user_data, health_data)
|
|
|
|
# 3. Claude API 호출
|
|
claude_response = await self.claude_client.call_claude_api(prompt)
|
|
|
|
# 4. Claude 응답 파싱
|
|
claude_json = self.claude_client.parse_json_response(claude_response)
|
|
|
|
# 5. 응답 형식 변환
|
|
recommended_missions = self._convert_claude_to_missions(claude_json)
|
|
|
|
# 6. 최종 응답 생성
|
|
response = MissionRecommendationResponse(missions=recommended_missions)
|
|
|
|
self.log_operation("recommend_missions_success", user_id=user_id,
|
|
mission_count=len(recommended_missions))
|
|
|
|
return response
|
|
|
|
except Exception as e:
|
|
self.logger.error(f"미션 추천 프로세스 실패 - user_id: {user_id}, error: {str(e)}")
|
|
raise Exception(f"미션 추천 실패: {str(e)}")
|
|
|
|
async def generate_celebration_message(self, user_id: int, mission_id: int) -> CelebrationResponse:
|
|
"""미션 달성 축하 메시지 생성 및 Chat DB 저장 (완료 후 저장)"""
|
|
try:
|
|
self.log_operation("generate_celebration_message_start",
|
|
user_id=user_id, mission_id=mission_id)
|
|
|
|
# 1. 미션 정보 조회
|
|
mission_info = await self._get_mission_info(mission_id)
|
|
|
|
# 2. 프롬프트 생성
|
|
prompt = self._build_celebration_prompt(user_id, mission_info)
|
|
|
|
# 3. Claude API 호출
|
|
claude_response = await self.claude_client.call_claude_api(prompt)
|
|
|
|
# 4. 응답 정제 (앞뒤 공백 제거, 따옴표 제거)
|
|
celebration_message = claude_response.strip().strip('"').strip("'")
|
|
|
|
# 5. 축하 메시지 생성 완료 후 Chat DB에 저장
|
|
celebration_message_id = await self.chat_repository.save_chat_message(
|
|
user_id=user_id,
|
|
message_type="celebration",
|
|
message_content=None, # message_content는 null
|
|
response_content=celebration_message # 축하 메시지는 response_content에 저장
|
|
)
|
|
|
|
# 6. 최종 응답 생성
|
|
response = CelebrationResponse(congratsMessage=celebration_message)
|
|
|
|
self.log_operation("generate_celebration_message_success",
|
|
user_id=user_id, mission_id=mission_id,
|
|
celebration_id=celebration_message_id,
|
|
mission_name=mission_info.get("mission_name"),
|
|
message_length=len(celebration_message))
|
|
|
|
return response
|
|
|
|
except (UserNotFoundException, HealthDataNotFoundException, DatabaseException):
|
|
# 사용자 정의 예외는 그대로 전파
|
|
raise
|
|
except Exception as e:
|
|
self.logger.error(f"축하 메시지 생성 실패 - user_id: {user_id}, mission_id: {mission_id}, error: {str(e)}")
|
|
raise ClaudeAPIException(f"축하 메시지 생성 중 오류가 발생했습니다: {str(e)}")
|
|
|
|
async def get_similar_mission_news(self, user_id: int) -> SimilarMissionNewsResponse:
|
|
"""5가지 유사도 기준별 미션 완료 소식 조회 (각 기준별 1명씩 총 5개)"""
|
|
try:
|
|
self.log_operation("get_similar_mission_news_start", user_id=user_id)
|
|
|
|
# 1. 유사 사용자 목록 조회 (더 많은 후보 확보)
|
|
similar_users = await self._get_cached_similar_users(user_id, top_k=30)
|
|
|
|
if not similar_users:
|
|
return SimilarMissionNewsResponse(similar_mission_news=[], total_count=0)
|
|
|
|
# 2. 유사 사용자들의 최근 미션 완료 이력 실시간 조회
|
|
recent_completions = await self.similar_mission_repository.get_recent_mission_completions(similar_users)
|
|
|
|
# 3. 현재 사용자 정보 조회 (비교 기준용)
|
|
current_user_data = await self.similar_mission_repository.get_user_health_for_vector(user_id)
|
|
|
|
# 4. 5가지 유사도 기준별 최적 후보 선별
|
|
mission_news_items = await self._select_diverse_mission_news(recent_completions, current_user_data)
|
|
|
|
# 5. 최종 응답 생성
|
|
response = SimilarMissionNewsResponse(
|
|
similar_mission_news=mission_news_items,
|
|
total_count=len(mission_news_items)
|
|
)
|
|
|
|
self.log_operation("get_similar_mission_news_success", user_id=user_id,
|
|
similar_user_count=len(similar_users),
|
|
news_count=len(mission_news_items))
|
|
|
|
return response
|
|
|
|
except Exception as e:
|
|
self.logger.error(f"유사 미션 소식 조회 실패 - user_id: {user_id}, error: {str(e)}")
|
|
raise Exception(f"유사 미션 소식 조회 실패: {str(e)}")
|
|
|
|
async def _select_diverse_mission_news(self, recent_completions: List[Dict[str, Any]],
|
|
current_user_data: Dict[str, Any]) -> List[MissionNewsItem]:
|
|
"""5가지 유사도 기준별 최적 후보 선별 (정상 특성 제외)"""
|
|
try:
|
|
if not current_user_data:
|
|
return []
|
|
|
|
# 5가지 유사도 기준별 최고 점수 후보들
|
|
similarity_categories = {
|
|
"occupation": {"best_completion": None, "best_score": 0.0}, # 직업 유사도
|
|
"age": {"best_completion": None, "best_score": 0.0}, # 나이 유사도
|
|
"health_bmi": {"best_completion": None, "best_score": 0.0}, # BMI/체형 유사도 (비정상만)
|
|
"health_bp": {"best_completion": None, "best_score": 0.0}, # 혈압 유사도 (주의/위험만)
|
|
"health_glucose": {"best_completion": None, "best_score": 0.0} # 혈당 유사도 (주의/위험만)
|
|
}
|
|
|
|
# 각 완료 이력에 대해 5가지 기준별 점수 계산
|
|
for completion in recent_completions:
|
|
# 1. 직업 유사도 계산
|
|
occupation_score = self._calculate_occupation_similarity(completion, current_user_data)
|
|
if occupation_score > similarity_categories["occupation"]["best_score"]:
|
|
similarity_categories["occupation"]["best_completion"] = completion
|
|
similarity_categories["occupation"]["best_score"] = occupation_score
|
|
|
|
# 2. 나이 유사도 계산
|
|
age_score = self._calculate_age_similarity(completion, current_user_data)
|
|
if age_score > similarity_categories["age"]["best_score"]:
|
|
similarity_categories["age"]["best_completion"] = completion
|
|
similarity_categories["age"]["best_score"] = age_score
|
|
|
|
# 3. BMI/체형 유사도 계산 (정상 체형 제외)
|
|
bmi_score = self._calculate_bmi_similarity(completion, current_user_data)
|
|
if bmi_score > similarity_categories["health_bmi"]["best_score"] and bmi_score > 0.3:
|
|
# 정상 체형은 낮은 점수(0.1)이므로 0.3 임계값으로 필터링됨
|
|
similarity_categories["health_bmi"]["best_completion"] = completion
|
|
similarity_categories["health_bmi"]["best_score"] = bmi_score
|
|
|
|
# 4. 혈압 유사도 계산 (정상 혈압 제외)
|
|
bp_score = self._calculate_bp_similarity(completion, current_user_data)
|
|
if bp_score > similarity_categories["health_bp"]["best_score"] and bp_score > 0.3:
|
|
# 정상 혈압은 낮은 점수(0.1)이므로 0.3 임계값으로 필터링됨
|
|
similarity_categories["health_bp"]["best_completion"] = completion
|
|
similarity_categories["health_bp"]["best_score"] = bp_score
|
|
|
|
# 5. 혈당 유사도 계산 (정상 혈당 제외)
|
|
glucose_score = self._calculate_glucose_similarity(completion, current_user_data)
|
|
if glucose_score > similarity_categories["health_glucose"]["best_score"] and glucose_score > 0.3:
|
|
# 정상 혈당은 낮은 점수(0.1)이므로 0.3 임계값으로 필터링됨
|
|
similarity_categories["health_glucose"]["best_completion"] = completion
|
|
similarity_categories["health_glucose"]["best_score"] = glucose_score
|
|
|
|
# 선별된 후보들을 MissionNewsItem으로 변환
|
|
mission_news_items = []
|
|
used_user_ids = set() # 중복 사용자 방지
|
|
|
|
for category, data in similarity_categories.items():
|
|
completion = data["best_completion"]
|
|
score = data["best_score"]
|
|
|
|
if completion and score > 0.3: # 최소 유사도 임계값 (정상 특성들은 자동 필터링됨)
|
|
user_id = completion.get("member_serial_number")
|
|
|
|
# 중복 사용자 체크
|
|
if user_id not in used_user_ids:
|
|
news_item = await self._create_mission_news_item(completion, current_user_data, category, score)
|
|
if news_item:
|
|
mission_news_items.append(news_item)
|
|
used_user_ids.add(user_id)
|
|
|
|
# 유사도 점수 순으로 정렬
|
|
mission_news_items.sort(key=lambda x: x.similarity_score, reverse=True)
|
|
|
|
# 최대 5개 반환
|
|
return mission_news_items[:5]
|
|
|
|
except Exception as e:
|
|
self.logger.error(f"다양한 미션 소식 선별 실패: {str(e)}")
|
|
return []
|
|
|
|
def _calculate_occupation_similarity(self, completion: Dict[str, Any], current_user: Dict[str, Any]) -> float:
|
|
"""직업 유사도 계산"""
|
|
try:
|
|
completion_occupation = completion.get("occupation", "")
|
|
current_occupation = current_user.get("occupation", "")
|
|
|
|
if not completion_occupation or not current_occupation:
|
|
return 0.0
|
|
|
|
if completion_occupation == current_occupation:
|
|
return 1.0
|
|
|
|
# 유사 직업군 체크
|
|
similar_groups = [
|
|
["OFF001", "ENG001"], # 테크 그룹
|
|
["MED001", "EDU001"], # 케어 그룹
|
|
["SRV001"] # 서비스 그룹
|
|
]
|
|
|
|
for group in similar_groups:
|
|
if completion_occupation in group and current_occupation in group:
|
|
return 0.6
|
|
|
|
return 0.0
|
|
except:
|
|
return 0.0
|
|
|
|
def _calculate_age_similarity(self, completion: Dict[str, Any], current_user: Dict[str, Any]) -> float:
|
|
"""나이 유사도 계산"""
|
|
try:
|
|
completion_age = completion.get("age", 0)
|
|
current_age = current_user.get("age", 0)
|
|
|
|
if not completion_age or not current_age:
|
|
return 0.0
|
|
|
|
age_diff = abs(completion_age - current_age)
|
|
return max(0.0, 1.0 - age_diff / 10) # 10세 차이까지 고려
|
|
except:
|
|
return 0.0
|
|
|
|
def _calculate_bmi_similarity(self, completion: Dict[str, Any], current_user: Dict[str, Any]) -> float:
|
|
"""BMI/체형 유사도 계산 (정상 체형 제외 로직)"""
|
|
try:
|
|
completion_bmi = self._calculate_bmi_from_data(completion)
|
|
current_bmi = current_user.get("bmi", 0) or self._calculate_bmi_from_data(current_user)
|
|
|
|
if not completion_bmi or not current_bmi:
|
|
return 0.0
|
|
|
|
# 정상 체형인 경우 유사도 낮춤 (표시되지 않도록)
|
|
if 18.5 <= completion_bmi < 25:
|
|
return 0.1 # 매우 낮은 유사도
|
|
|
|
bmi_diff = abs(completion_bmi - current_bmi)
|
|
return max(0.0, 1.0 - bmi_diff / 6) # BMI 6 차이까지 고려
|
|
except:
|
|
return 0.0
|
|
|
|
def _calculate_bp_similarity(self, completion: Dict[str, Any], current_user: Dict[str, Any]) -> float:
|
|
"""혈압 유사도 계산 (정상 혈압 제외 로직)"""
|
|
try:
|
|
completion_bp = completion.get("systolic_bp", 0)
|
|
current_bp = current_user.get("systolic_bp", 0)
|
|
|
|
if not completion_bp or not current_bp:
|
|
return 0.0
|
|
|
|
# 정상 혈압인 경우 유사도 낮춤 (표시되지 않도록)
|
|
if completion_bp < 130:
|
|
return 0.1 # 매우 낮은 유사도
|
|
|
|
bp_diff = abs(completion_bp - current_bp)
|
|
return max(0.0, 1.0 - bp_diff / 30) # 혈압 30 차이까지 고려
|
|
except:
|
|
return 0.0
|
|
|
|
def _calculate_glucose_similarity(self, completion: Dict[str, Any], current_user: Dict[str, Any]) -> float:
|
|
"""혈당 유사도 계산 (정상 혈당 제외 로직)"""
|
|
try:
|
|
completion_glucose = completion.get("fasting_glucose", 0)
|
|
current_glucose = current_user.get("fasting_glucose", 0)
|
|
|
|
if not completion_glucose or not current_glucose:
|
|
return 0.0
|
|
|
|
# 정상 혈당인 경우 유사도 낮춤 (표시되지 않도록)
|
|
if completion_glucose < 100:
|
|
return 0.1 # 매우 낮은 유사도
|
|
|
|
glucose_diff = abs(completion_glucose - current_glucose)
|
|
return max(0.0, 1.0 - glucose_diff / 30) # 혈당 30 차이까지 고려
|
|
except:
|
|
return 0.0
|
|
|
|
async def _create_mission_news_item(self, completion: Dict[str, Any], current_user_data: Dict[str, Any],
|
|
category: str, score: float) -> Optional[MissionNewsItem]:
|
|
"""개별 미션 소식 아이템 생성"""
|
|
try:
|
|
# 이름 마스킹
|
|
full_name = completion.get("name", "사용자")
|
|
masked_name = self._mask_name(full_name)
|
|
|
|
# 카테고리별 특성 분석
|
|
user_characteristics = self._analyze_category_characteristics(completion, current_user_data, category)
|
|
|
|
# 미션 정보
|
|
mission_name = completion.get("mission_name", "미션")
|
|
completed_count = completion.get("daily_completed_count", 1)
|
|
|
|
# 미션 카테고리 추출
|
|
mission_category = self._extract_mission_category(mission_name)
|
|
|
|
# 간단한 이모지 매핑 (성능 향상)
|
|
emoji = self._get_simple_emoji(mission_name)
|
|
|
|
# 메시지 생성
|
|
message = self._generate_category_based_message(
|
|
masked_name, mission_name, completed_count, user_characteristics, emoji
|
|
)
|
|
|
|
# MissionNewsItem 생성
|
|
news_item = MissionNewsItem(
|
|
message=message,
|
|
mission_category=mission_category,
|
|
similarity_score=score,
|
|
completed_at=completion.get("created_at", datetime.now())
|
|
)
|
|
|
|
return news_item
|
|
|
|
except Exception as e:
|
|
self.logger.warning(f"미션 소식 아이템 생성 실패: {str(e)}")
|
|
return None
|
|
|
|
def _analyze_category_characteristics(self, completion: Dict[str, Any], current_user_data: Dict[str, Any],
|
|
category: str) -> Dict[str, Any]:
|
|
"""카테고리별 특성 분석 (정상 특성 제외)"""
|
|
characteristics = {
|
|
"primary_identifier": "",
|
|
"category": category
|
|
}
|
|
|
|
try:
|
|
if category == "occupation":
|
|
# 직업 중심
|
|
occupation_code = completion.get("occupation", "")
|
|
characteristics["primary_identifier"] = self._get_occupation_display_name(occupation_code)
|
|
|
|
elif category == "age":
|
|
# 나이 중심
|
|
age = completion.get("age", 0)
|
|
if age:
|
|
characteristics["primary_identifier"] = f"{age}세"
|
|
|
|
elif category == "health_bmi":
|
|
# BMI/체형 중심 (정상 체형 제외)
|
|
bmi = self._calculate_bmi_from_data(completion)
|
|
if bmi:
|
|
if bmi < 18.5:
|
|
characteristics["primary_identifier"] = "마른"
|
|
elif bmi >= 25:
|
|
characteristics["primary_identifier"] = "통통한"
|
|
# 정상 체형(18.5-25)은 표시하지 않음
|
|
|
|
elif category == "health_bp":
|
|
# 혈압 중심 (정상 혈압 제외)
|
|
systolic_bp = completion.get("systolic_bp", 0)
|
|
if systolic_bp:
|
|
if systolic_bp >= 140:
|
|
characteristics["primary_identifier"] = "혈압높은"
|
|
elif systolic_bp >= 130:
|
|
characteristics["primary_identifier"] = "혈압주의"
|
|
# 정상 혈압(<130)은 표시하지 않음
|
|
|
|
elif category == "health_glucose":
|
|
# 혈당 중심 (정상 혈당 제외)
|
|
glucose = completion.get("fasting_glucose", 0)
|
|
if glucose:
|
|
if glucose >= 126:
|
|
characteristics["primary_identifier"] = "혈당높은"
|
|
elif glucose >= 100:
|
|
characteristics["primary_identifier"] = "혈당주의"
|
|
# 정상 혈당(<100)은 표시하지 않음
|
|
|
|
except Exception as e:
|
|
self.logger.warning(f"카테고리별 특성 분석 실패: {str(e)}")
|
|
|
|
return characteristics
|
|
|
|
def _generate_category_based_message(self, masked_name: str, mission_name: str, completed_count: int,
|
|
characteristics: Dict[str, Any], emoji: str) -> str:
|
|
"""카테고리 기반 메시지 생성 (정상 특성 제외)"""
|
|
try:
|
|
primary_identifier = characteristics.get("primary_identifier", "")
|
|
|
|
# 식별자가 있으면 사용, 없으면 기본 이름만 사용
|
|
# 정상 특성들은 primary_identifier가 빈 문자열이므로 자동으로 기본 이름만 사용됨
|
|
if primary_identifier:
|
|
identifier = f"{primary_identifier} {masked_name}님"
|
|
else:
|
|
identifier = f"{masked_name}님"
|
|
|
|
# 미션 완료 메시지 생성
|
|
if completed_count > 1:
|
|
message = f"{identifier}이 {mission_name} {completed_count}회를 완료했어요! {emoji}"
|
|
else:
|
|
message = f"{identifier}이 {mission_name}을 완료했어요! {emoji}"
|
|
|
|
return message
|
|
|
|
except Exception as e:
|
|
self.logger.warning(f"카테고리 기반 메시지 생성 실패: {str(e)}")
|
|
return f"{masked_name}님이 미션을 완료했어요! 🎉"
|
|
|
|
def _get_simple_emoji(self, mission_name: str) -> str:
|
|
"""간단한 이모지 매핑 (성능 향상)"""
|
|
try:
|
|
mission_name_lower = mission_name.lower()
|
|
|
|
# 키워드 기반 이모지 매핑
|
|
emoji_map = {
|
|
"물": "💧", "마시기": "💧", "water": "💧",
|
|
"걷기": "🚶♀️", "산책": "🚶♂️", "운동": "💪", "walk": "🚶♀️",
|
|
"스트레칭": "🧘♂️", "stretch": "🧘♀️",
|
|
"명상": "🧘♀️", "meditation": "🧘♂️",
|
|
"수면": "😴", "잠": "😴", "sleep": "😴",
|
|
"계단": "🏃♂️", "오르기": "🏃♀️", "stairs": "🏃♂️",
|
|
"식단": "🥗", "음식": "🍎", "diet": "🥗",
|
|
"호흡": "🌬️", "breathing": "🌬️"
|
|
}
|
|
|
|
for keyword, emoji in emoji_map.items():
|
|
if keyword in mission_name_lower:
|
|
return emoji
|
|
|
|
return "🎉" # 기본 이모지
|
|
|
|
except Exception as e:
|
|
self.logger.warning(f"간단한 이모지 매핑 실패: {str(e)}")
|
|
return "🎉"
|
|
|
|
async def _format_mission_news(self, recent_completions: List[Dict[str, Any]],
|
|
current_user_id: int) -> List[MissionNewsItem]:
|
|
"""미션 완료 이력을 소식 메시지로 포맷팅 (다층 특성 기반 개선)"""
|
|
try:
|
|
mission_news_items = []
|
|
|
|
# 현재 사용자 정보 조회 (비교 기준용)
|
|
current_user_data = await self.similar_mission_repository.get_user_health_for_vector(current_user_id)
|
|
|
|
for completion in recent_completions:
|
|
# 이름 마스킹 (김OO 형식)
|
|
full_name = completion.get("name", "사용자")
|
|
masked_name = self._mask_name(full_name)
|
|
|
|
# 다층 특성 분석 (나이, 건강, 직업)
|
|
user_characteristics = self._analyze_multi_layer_characteristics(completion, current_user_data)
|
|
|
|
# 미션 정보
|
|
mission_name = completion.get("mission_name", "미션")
|
|
completed_count = completion.get("daily_completed_count", 1)
|
|
|
|
# 미션 카테고리 추출
|
|
mission_category = self._extract_mission_category(mission_name)
|
|
|
|
# 다층 특성 기반 메시지 생성 (AI 이모지 자동 매핑 포함)
|
|
message = await self._generate_multi_layer_mission_message(
|
|
masked_name, mission_name, completed_count, user_characteristics
|
|
)
|
|
|
|
# 유사도 점수 계산 (직업 가중치 증가)
|
|
similarity_score = self._calculate_enhanced_user_similarity(completion, current_user_data)
|
|
|
|
# MissionNewsItem 생성
|
|
news_item = MissionNewsItem(
|
|
message=message,
|
|
mission_category=mission_category,
|
|
similarity_score=similarity_score,
|
|
completed_at=completion.get("created_at", datetime.now())
|
|
)
|
|
|
|
mission_news_items.append(news_item)
|
|
|
|
# 유사도 순으로 정렬 후 최신순으로 재정렬
|
|
mission_news_items.sort(key=lambda x: x.similarity_score, reverse=True)
|
|
mission_news_items = mission_news_items[:15] # 상위 15개 선택
|
|
mission_news_items.sort(key=lambda x: x.completed_at, reverse=True) # 최신순 정렬
|
|
|
|
return mission_news_items[:10] # 최종 10개 반환
|
|
|
|
except Exception as e:
|
|
self.logger.error(f"미션 소식 포맷팅 실패: {str(e)}")
|
|
return []
|
|
|
|
def _analyze_multi_layer_characteristics(self, user_completion: Dict[str, Any],
|
|
current_user_data: Dict[str, Any]) -> Dict[str, Any]:
|
|
"""다층 사용자 특성 분석 (나이, 건강, 직업)"""
|
|
characteristics = {
|
|
"age_char": "",
|
|
"health_chars": [],
|
|
"occupation_char": "",
|
|
"primary_type": "", # age, health, occupation 중 가장 강한 특성
|
|
"similarity_strength": 0.0
|
|
}
|
|
|
|
if not current_user_data:
|
|
return characteristics
|
|
|
|
try:
|
|
# 1. 나이 특성 분석
|
|
completion_age = user_completion.get("age", 0)
|
|
current_age = current_user_data.get("age", 0)
|
|
age_similarity = 0.0
|
|
|
|
if completion_age and current_age:
|
|
age_diff = abs(completion_age - current_age)
|
|
if age_diff <= 3: # 3세 이내
|
|
characteristics["age_char"] = f"{completion_age}세"
|
|
age_similarity = 1.0 - (age_diff / 10)
|
|
elif age_diff <= 7: # 7세 이내
|
|
if completion_age < 30:
|
|
characteristics["age_char"] = f"{completion_age}세"
|
|
elif completion_age < 40:
|
|
characteristics["age_char"] = f"30대"
|
|
else:
|
|
characteristics["age_char"] = f"40대"
|
|
age_similarity = 0.7 - (age_diff / 20)
|
|
|
|
# 2. 건강 특성 분석
|
|
health_chars = []
|
|
health_similarity = 0.0
|
|
|
|
# BMI 특성
|
|
completion_bmi = self._calculate_bmi_from_data(user_completion)
|
|
current_bmi = current_user_data.get("bmi", 0) or self._calculate_bmi_from_data(current_user_data)
|
|
if completion_bmi and current_bmi:
|
|
if completion_bmi < 18.5 and current_bmi < 20:
|
|
health_chars.append("마른")
|
|
health_similarity += 0.2
|
|
elif completion_bmi >= 25 and current_bmi >= 23:
|
|
health_chars.append("통통한")
|
|
health_similarity += 0.2
|
|
|
|
# 혈압 특성
|
|
completion_systolic = user_completion.get("systolic_bp", 0)
|
|
current_systolic = current_user_data.get("systolic_bp", 0)
|
|
if completion_systolic and current_systolic:
|
|
if completion_systolic >= 140 and current_systolic >= 130:
|
|
health_chars.append("혈압높은")
|
|
health_similarity += 0.25
|
|
elif completion_systolic >= 130 and current_systolic >= 120:
|
|
health_chars.append("혈압주의")
|
|
health_similarity += 0.15
|
|
|
|
# 혈당 특성
|
|
completion_glucose = user_completion.get("fasting_glucose", 0)
|
|
current_glucose = current_user_data.get("fasting_glucose", 0)
|
|
if completion_glucose and current_glucose:
|
|
if completion_glucose >= 126 and current_glucose >= 110:
|
|
health_chars.append("혈당높은")
|
|
health_similarity += 0.25
|
|
elif completion_glucose >= 100 and current_glucose >= 95:
|
|
health_chars.append("혈당주의")
|
|
health_similarity += 0.15
|
|
|
|
# 콜레스테롤 특성
|
|
completion_chol = user_completion.get("total_cholesterol", 0)
|
|
current_chol = current_user_data.get("total_cholesterol", 0)
|
|
if completion_chol and current_chol:
|
|
if completion_chol >= 240 and current_chol >= 220:
|
|
health_chars.append("콜레스테롤높은")
|
|
health_similarity += 0.2
|
|
|
|
# 간기능 특성
|
|
completion_alt = user_completion.get("alt", 0)
|
|
current_alt = current_user_data.get("alt", 0)
|
|
if completion_alt and current_alt:
|
|
if completion_alt >= 40 and current_alt >= 35:
|
|
health_chars.append("간수치높은")
|
|
health_similarity += 0.15
|
|
|
|
characteristics["health_chars"] = health_chars
|
|
|
|
# 3. 직업 특성 분석
|
|
occupation_similarity = 0.0
|
|
completion_occupation = user_completion.get("occupation", "")
|
|
current_occupation = current_user_data.get("occupation", "")
|
|
|
|
if completion_occupation and current_occupation:
|
|
if completion_occupation == current_occupation:
|
|
# 동일 직업
|
|
occupation_name = self._get_occupation_display_name(completion_occupation)
|
|
characteristics["occupation_char"] = occupation_name
|
|
occupation_similarity = 1.0
|
|
else:
|
|
# 유사 직업군 체크
|
|
similar_groups = {
|
|
"tech": ["OFF001", "ENG001"], # 사무직, 엔지니어
|
|
"care": ["MED001", "EDU001"], # 의료진, 교육직
|
|
"service": ["SRV001"] # 서비스직
|
|
}
|
|
|
|
for group_name, occupations in similar_groups.items():
|
|
if completion_occupation in occupations and current_occupation in occupations:
|
|
occupation_name = self._get_occupation_display_name(completion_occupation)
|
|
characteristics["occupation_char"] = occupation_name
|
|
occupation_similarity = 0.7
|
|
break
|
|
|
|
# 4. 주요 특성 타입 결정
|
|
similarities = {
|
|
"occupation": occupation_similarity,
|
|
"health": health_similarity,
|
|
"age": age_similarity
|
|
}
|
|
|
|
# 가장 높은 유사도의 특성을 주요 타입으로 설정
|
|
primary_type = max(similarities, key=similarities.get)
|
|
characteristics["primary_type"] = primary_type
|
|
characteristics["similarity_strength"] = similarities[primary_type]
|
|
|
|
self.logger.debug(f"다층 특성 분석 완료 - "
|
|
f"나이: {characteristics['age_char']}, "
|
|
f"건강: {health_chars}, "
|
|
f"직업: {characteristics['occupation_char']}, "
|
|
f"주요타입: {primary_type}")
|
|
|
|
except Exception as e:
|
|
self.logger.warning(f"다층 특성 분석 중 오류: {str(e)}")
|
|
|
|
return characteristics
|
|
|
|
def _get_occupation_display_name(self, occupation_code: str) -> str:
|
|
"""직업 코드를 표시용 이름으로 변환"""
|
|
occupation_display = {
|
|
"OFF001": "사무직",
|
|
"MED001": "의료진",
|
|
"EDU001": "교육직",
|
|
"ENG001": "IT직군",
|
|
"SRV001": "서비스직"
|
|
}
|
|
return occupation_display.get(occupation_code, "직장인")
|
|
|
|
async def _generate_multi_layer_mission_message(self, masked_name: str, mission_name: str,
|
|
completed_count: int, characteristics: Dict[str, Any]) -> str:
|
|
"""다층 특성을 고려한 미션 완료 소식 메시지 생성 (AI 이모지 자동 매핑)"""
|
|
try:
|
|
# 특성 조합 및 우선순위 결정
|
|
primary_type = characteristics.get("primary_type", "")
|
|
age_char = characteristics.get("age_char", "")
|
|
health_chars = characteristics.get("health_chars", [])
|
|
occupation_char = characteristics.get("occupation_char", "")
|
|
|
|
# 메시지 식별자 생성 로직
|
|
identifier = self._build_identifier(primary_type, age_char, health_chars, occupation_char, masked_name)
|
|
|
|
# AI를 통한 이모지 자동 매핑
|
|
emoji = await self._get_ai_emoji(mission_name)
|
|
|
|
# 미션 완료 메시지 생성
|
|
if completed_count > 1:
|
|
message = f"{identifier}이 {mission_name} {completed_count}회를 완료했어요! {emoji}"
|
|
else:
|
|
message = f"{identifier}이 {mission_name}을 완료했어요! {emoji}"
|
|
|
|
return message
|
|
|
|
except Exception as e:
|
|
self.logger.error(f"다층 미션 메시지 생성 실패: {str(e)}")
|
|
return f"{masked_name}님이 미션을 완료했어요! 🎉"
|
|
|
|
async def _get_ai_emoji(self, mission_name: str) -> str:
|
|
"""AI를 통한 미션별 적절한 이모지 자동 선택"""
|
|
try:
|
|
# 이모지 선택 프롬프트
|
|
emoji_prompt = f"""
|
|
다음 미션에 가장 적절한 이모지 1개를 선택해서 이모지만 답변해주세요.
|
|
|
|
미션: {mission_name}
|
|
|
|
아래 이모지 중에서 가장 적절한 것을 선택하거나, 더 적절한 이모지가 있다면 그것을 사용해주세요:
|
|
💧 🚶♀️ 🚶♂️ 💪 🧘♂️ 🧘♀️ 😴 🏃♂️ 🏃♀️ 🥗 🍎 🧠 ❤️ 🌟 ✨ 🎯 🏆 💯 🔥 ⚡ 🌱
|
|
|
|
이모지만 답변하세요.
|
|
"""
|
|
|
|
# Claude API 호출 (짧은 응답)
|
|
emoji_response = await self.claude_client.call_claude_api(emoji_prompt)
|
|
|
|
# 응답에서 이모지 추출 (첫 번째 이모지 사용)
|
|
emoji = emoji_response.strip()
|
|
|
|
# 이모지 검증 (길이가 1-2자가 아니거나 특수 문자가 아니면 기본값 사용)
|
|
if len(emoji) > 4 or not emoji:
|
|
emoji = "🎉"
|
|
|
|
self.logger.debug(f"AI 이모지 선택: {mission_name} -> {emoji}")
|
|
return emoji
|
|
|
|
except Exception as e:
|
|
self.logger.warning(f"AI 이모지 선택 실패, 기본값 사용: {str(e)}")
|
|
return "🎉"
|
|
|
|
def _build_identifier(self, primary_type: str, age_char: str, health_chars: List[str],
|
|
occupation_char: str, masked_name: str) -> str:
|
|
"""특성 기반 식별자 생성"""
|
|
try:
|
|
# 1순위: 직업이 주요 특성이고 직업 정보가 있는 경우
|
|
if primary_type == "occupation" and occupation_char:
|
|
return f"{occupation_char} {masked_name}님"
|
|
|
|
# 2순위: 건강이 주요 특성이고 건강 특성이 있는 경우
|
|
elif primary_type == "health" and health_chars:
|
|
if len(health_chars) >= 2:
|
|
# 건강 특성 2개 이상: 첫 번째와 두 번째 조합
|
|
return f"{health_chars[0]} {health_chars[1]} {masked_name}님"
|
|
else:
|
|
# 건강 특성 1개: 나이와 조합 시도
|
|
if age_char:
|
|
return f"{age_char} {health_chars[0]} {masked_name}님"
|
|
else:
|
|
return f"{health_chars[0]} {masked_name}님"
|
|
|
|
# 3순위: 나이가 주요 특성이고 나이 정보가 있는 경우
|
|
elif primary_type == "age" and age_char:
|
|
# 나이 + 건강 특성 조합 시도
|
|
if health_chars:
|
|
return f"{age_char} {health_chars[0]} {masked_name}님"
|
|
else:
|
|
return f"{age_char} {masked_name}님"
|
|
|
|
# 보조 로직: 주요 특성이 없거나 약한 경우 다른 특성 활용
|
|
else:
|
|
# 직업 정보 우선 활용
|
|
if occupation_char:
|
|
return f"{occupation_char} {masked_name}님"
|
|
# 건강 특성 활용
|
|
elif health_chars:
|
|
if age_char:
|
|
return f"{age_char} {health_chars[0]} {masked_name}님"
|
|
else:
|
|
return f"{health_chars[0]} {masked_name}님"
|
|
# 나이만 있는 경우
|
|
elif age_char:
|
|
return f"{age_char} {masked_name}님"
|
|
# 모든 특성이 없는 경우
|
|
else:
|
|
return f"{masked_name}님"
|
|
|
|
except Exception as e:
|
|
self.logger.warning(f"식별자 생성 실패: {str(e)}")
|
|
return f"{masked_name}님"
|
|
|
|
def _calculate_enhanced_user_similarity(self, completion_user: Dict[str, Any],
|
|
current_user: Dict[str, Any]) -> float:
|
|
"""강화된 사용자 간 유사도 계산 (직업 가중치 증가)"""
|
|
if not current_user:
|
|
return 0.5 # 기본 유사도
|
|
|
|
try:
|
|
similarity_score = 0.0
|
|
weight_sum = 0.0
|
|
|
|
# 직업 유사도 (가중치: 25% 증가)
|
|
completion_occupation = completion_user.get("occupation", "")
|
|
current_occupation = current_user.get("occupation", "")
|
|
if completion_occupation and current_occupation:
|
|
if completion_occupation == current_occupation:
|
|
occupation_similarity = 1.0
|
|
else:
|
|
# 유사 직업군 체크
|
|
similar_groups = {
|
|
"tech": ["OFF001", "ENG001"], # 사무직, 엔지니어
|
|
"care": ["MED001", "EDU001"], # 의료진, 교육직
|
|
"service": ["SRV001"] # 서비스직
|
|
}
|
|
|
|
occupation_similarity = 0.0
|
|
for group_name, occupations in similar_groups.items():
|
|
if completion_occupation in occupations and current_occupation in occupations:
|
|
occupation_similarity = 0.6
|
|
break
|
|
|
|
similarity_score += occupation_similarity * 0.25
|
|
weight_sum += 0.25
|
|
|
|
# 나이 유사도 (가중치: 25%)
|
|
completion_age = completion_user.get("age", 0)
|
|
current_age = current_user.get("age", 0)
|
|
if completion_age and current_age:
|
|
age_diff = abs(completion_age - current_age)
|
|
age_similarity = max(0.0, 1.0 - age_diff / 15) # 15세 차이까지 고려
|
|
similarity_score += age_similarity * 0.25
|
|
weight_sum += 0.25
|
|
|
|
# BMI 유사도 (가중치: 15%)
|
|
completion_bmi = self._calculate_bmi_from_data(completion_user)
|
|
current_bmi = current_user.get("bmi", 0) or self._calculate_bmi_from_data(current_user)
|
|
if completion_bmi and current_bmi:
|
|
bmi_diff = abs(completion_bmi - current_bmi)
|
|
bmi_similarity = max(0.0, 1.0 - bmi_diff / 8) # BMI 8 차이까지 고려
|
|
similarity_score += bmi_similarity * 0.15
|
|
weight_sum += 0.15
|
|
|
|
# 혈압 유사도 (가중치: 12%)
|
|
completion_bp = completion_user.get("systolic_bp", 0)
|
|
current_bp = current_user.get("systolic_bp", 0)
|
|
if completion_bp and current_bp:
|
|
bp_diff = abs(completion_bp - current_bp)
|
|
bp_similarity = max(0.0, 1.0 - bp_diff / 40) # 혈압 40 차이까지 고려
|
|
similarity_score += bp_similarity * 0.12
|
|
weight_sum += 0.12
|
|
|
|
# 혈당 유사도 (가중치: 12%)
|
|
completion_glucose = completion_user.get("fasting_glucose", 0)
|
|
current_glucose = current_user.get("fasting_glucose", 0)
|
|
if completion_glucose and current_glucose:
|
|
glucose_diff = abs(completion_glucose - current_glucose)
|
|
glucose_similarity = max(0.0, 1.0 - glucose_diff / 40)
|
|
similarity_score += glucose_similarity * 0.12
|
|
weight_sum += 0.12
|
|
|
|
# 콜레스테롤 유사도 (가중치: 11%)
|
|
completion_chol = completion_user.get("total_cholesterol", 0)
|
|
current_chol = current_user.get("total_cholesterol", 0)
|
|
if completion_chol and current_chol:
|
|
chol_diff = abs(completion_chol - current_chol)
|
|
chol_similarity = max(0.0, 1.0 - chol_diff / 80)
|
|
similarity_score += chol_similarity * 0.11
|
|
weight_sum += 0.11
|
|
|
|
# 최종 유사도 계산
|
|
if weight_sum > 0:
|
|
final_similarity = similarity_score / weight_sum
|
|
else:
|
|
final_similarity = 0.5 # 기본값
|
|
|
|
return min(1.0, max(0.0, final_similarity))
|
|
|
|
except Exception as e:
|
|
self.logger.warning(f"강화된 유사도 계산 실패: {str(e)}")
|
|
return 0.5
|
|
|
|
def _calculate_bmi_from_data(self, user_data: Dict[str, Any]) -> float:
|
|
"""사용자 데이터에서 BMI 계산"""
|
|
try:
|
|
height = user_data.get("height", 0)
|
|
weight = user_data.get("weight", 0)
|
|
|
|
if height and weight and height > 0:
|
|
return round(weight / ((height / 100) ** 2), 1)
|
|
return 0.0
|
|
except:
|
|
return 0.0
|
|
|
|
def _mask_name(self, full_name: str) -> str:
|
|
"""이름 마스킹 (김OO 형식)"""
|
|
if len(full_name) <= 1:
|
|
return full_name + "OO"
|
|
elif len(full_name) == 2:
|
|
return full_name[0] + "O"
|
|
else:
|
|
return full_name[0] + "O" * (len(full_name) - 1)
|
|
|
|
def _extract_mission_category(self, mission_name: str) -> str:
|
|
"""미션명에서 카테고리 추출"""
|
|
mission_name_lower = mission_name.lower()
|
|
|
|
if any(keyword in mission_name_lower for keyword in ["물", "water", "마시기"]):
|
|
return "hydration"
|
|
elif any(keyword in mission_name_lower for keyword in ["걷기", "산책", "운동", "walk", "exercise"]):
|
|
return "exercise"
|
|
elif any(keyword in mission_name_lower for keyword in ["스트레칭", "stretch"]):
|
|
return "stretching"
|
|
elif any(keyword in mission_name_lower for keyword in ["명상", "meditation"]):
|
|
return "meditation"
|
|
elif any(keyword in mission_name_lower for keyword in ["수면", "잠", "sleep"]):
|
|
return "sleep"
|
|
else:
|
|
return "general"
|
|
|
|
async def upsert_user_vector_on_registration(self, user_id: int) -> bool:
|
|
"""사용자 회원가입/수정 시 벡터DB 저장"""
|
|
try:
|
|
self.log_operation("upsert_user_vector_start", user_id=user_id)
|
|
|
|
# 1. 사용자 건강 정보 조회
|
|
user_health_data = await self.similar_mission_repository.get_user_health_for_vector(user_id)
|
|
|
|
if not user_health_data:
|
|
raise UserNotFoundException(user_id)
|
|
|
|
# 2. Pinecone에 사용자 벡터 저장
|
|
success = await pinecone_client.upsert_user_vector(user_id, user_health_data)
|
|
|
|
if success:
|
|
# 3. 유사 사용자 캐시 무효화 (기존 캐시 삭제)
|
|
await self._invalidate_similar_users_cache(user_id)
|
|
|
|
self.log_operation("upsert_user_vector_success", user_id=user_id)
|
|
return True
|
|
else:
|
|
raise Exception("벡터 저장 실패")
|
|
|
|
except Exception as e:
|
|
self.logger.error(f"사용자 벡터 저장 실패 - user_id: {user_id}, error: {str(e)}")
|
|
return False
|
|
|
|
|
|
async def _get_cached_similar_users(self, user_id: int, top_k: int = 30) -> List[int]:
|
|
"""유사 사용자 목록 조회 (더 많은 후보 확보)"""
|
|
try:
|
|
cache_key = redis_client.generate_similar_users_key(user_id)
|
|
|
|
# Cache Aside 패턴 적용
|
|
async def fetch_similar_users():
|
|
return await pinecone_client.search_similar_users(user_id, top_k=top_k)
|
|
|
|
similar_users = await redis_client.get_or_set(
|
|
key=cache_key,
|
|
fetch_func=fetch_similar_users,
|
|
ttl=1800 # 30분 캐싱
|
|
)
|
|
|
|
self.log_operation("get_cached_similar_users", user_id=user_id,
|
|
similar_count=len(similar_users) if similar_users else 0)
|
|
|
|
return similar_users if similar_users else []
|
|
|
|
except Exception as e:
|
|
self.logger.error(f"유사 사용자 캐시 조회 실패 - user_id: {user_id}, error: {str(e)}")
|
|
# 캐시 실패 시 직접 벡터DB 조회
|
|
try:
|
|
return await pinecone_client.search_similar_users(user_id, top_k=top_k)
|
|
except:
|
|
return []
|
|
|
|
async def _invalidate_similar_users_cache(self, user_id: int):
|
|
"""유사 사용자 캐시 무효화"""
|
|
try:
|
|
cache_key = redis_client.generate_similar_users_key(user_id)
|
|
await redis_client.delete(cache_key)
|
|
|
|
self.log_operation("invalidate_similar_users_cache", user_id=user_id)
|
|
|
|
except Exception as e:
|
|
self.logger.error(f"캐시 무효화 실패 - user_id: {user_id}, error: {str(e)}")
|
|
|
|
# 기존 메소드들 (변경 없음)
|
|
|
|
|
|
async def _get_mission_info(self, mission_id: int) -> Dict[str, Any]:
|
|
"""미션 ID로 DB에서 미션 정보 조회"""
|
|
try:
|
|
mission_info = await self.mission_repository.get_mission_by_id(mission_id)
|
|
|
|
if not mission_info:
|
|
raise HealthDataNotFoundException(mission_id)
|
|
|
|
self.log_operation("get_mission_info_success",
|
|
mission_id=mission_id,
|
|
mission_name=mission_info.get("mission_name"))
|
|
|
|
return mission_info
|
|
|
|
except HealthDataNotFoundException:
|
|
# 미션을 찾을 수 없는 경우
|
|
raise
|
|
except Exception as e:
|
|
self.logger.error(f"미션 정보 조회 실패 - mission_id: {mission_id}, error: {str(e)}")
|
|
raise DatabaseException(f"미션 정보 조회 중 오류가 발생했습니다: {str(e)}")
|
|
|
|
|
|
def _build_celebration_prompt(self, user_id: int, mission_info: Dict[str, Any]) -> str:
|
|
"""미션 정보를 기반으로 축하 메시지 프롬프트 생성"""
|
|
try:
|
|
prompt_template = get_celebration_prompt()
|
|
|
|
# DB에서 조회한 실제 미션 정보 사용
|
|
mission_name = mission_info.get("mission_name", "미션")
|
|
mission_description = mission_info.get("mission_description", "")
|
|
daily_target = mission_info.get("daily_target_count", 1)
|
|
|
|
formatted_prompt = prompt_template.format(
|
|
mission_name=mission_name,
|
|
mission_description=mission_description,
|
|
daily_target_count=daily_target,
|
|
user_id=user_id
|
|
)
|
|
|
|
self.log_operation("build_celebration_prompt",
|
|
user_id=user_id,
|
|
mission_name=mission_name,
|
|
prompt_length=len(formatted_prompt))
|
|
return formatted_prompt
|
|
|
|
except Exception as e:
|
|
self.logger.error(f"축하 프롬프트 생성 실패: {str(e)}")
|
|
raise Exception(f"축하 프롬프트 생성 실패: {str(e)}")
|
|
|
|
|
|
async def _build_recommendation_prompt(self, user_data: Dict[str, Any], health_data: Dict[str, Any]) -> str:
|
|
"""사용자 데이터를 기반으로 프롬프트 생성"""
|
|
try:
|
|
prompt_template = get_mission_recommendation_prompt()
|
|
|
|
# 프롬프트에 데이터 매핑
|
|
formatted_prompt = prompt_template.format(
|
|
# 사용자 기본 정보
|
|
occupation=user_data.get("occupation", "정보 없음"),
|
|
age=health_data.get("age", "정보 없음"),
|
|
|
|
# 신체 정보
|
|
height=health_data.get("height", "정보 없음"),
|
|
weight=health_data.get("weight", "정보 없음"),
|
|
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_recommendation_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_claude_to_missions(self, claude_json: Dict[str, Any]) -> List[RecommendedMission]:
|
|
"""Claude JSON 응답을 RecommendedMission 리스트로 변환"""
|
|
try:
|
|
missions_data = claude_json.get("missions", [])
|
|
recommended_missions = []
|
|
|
|
for mission_data in missions_data:
|
|
mission = RecommendedMission(
|
|
title=mission_data.get("title", ""),
|
|
daily_target_count=mission_data.get("daily_target_count", 1),
|
|
reason=mission_data.get("reason", "")
|
|
)
|
|
recommended_missions.append(mission)
|
|
|
|
self.log_operation("convert_claude_to_missions", mission_count=len(recommended_missions))
|
|
return recommended_missions
|
|
|
|
except Exception as e:
|
|
self.logger.error(f"Claude 응답 변환 실패: {str(e)}")
|
|
raise Exception(f"Claude 응답 변환 실패: {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, "정보 없음")
|
|
|
|
async def upsert_all_user_vectors(self, reset_index: bool = False) -> Dict[str, Any]:
|
|
"""모든 사용자의 벡터를 일괄 저장/업데이트 (선택적 인덱스 초기화)"""
|
|
start_time = time.time()
|
|
|
|
try:
|
|
self.log_operation("upsert_all_user_vectors_start", reset_index=reset_index)
|
|
|
|
# 1. 선택적 인덱스 초기화
|
|
if reset_index:
|
|
self.logger.info("🔄 인덱스 초기화 옵션 활성화 - 모든 벡터 삭제 후 재생성")
|
|
|
|
reset_success = await pinecone_client.reset_index()
|
|
if not reset_success:
|
|
self.logger.warning("⚠️ 인덱스 초기화 실패 - 기존 벡터와 함께 진행")
|
|
else:
|
|
self.logger.info("✅ 인덱스 초기화 완료 - 깨끗한 상태에서 시작")
|
|
|
|
# 2. 전체 사용자 목록 조회
|
|
all_users = await self._get_all_users_for_vector_processing()
|
|
total_users = len(all_users)
|
|
|
|
if total_users == 0:
|
|
self.logger.warning("⚠️ 처리할 사용자가 없습니다.")
|
|
return {
|
|
"success": True,
|
|
"total_users": 0,
|
|
"existing_vectors": 0,
|
|
"new_vectors": 0,
|
|
"failed": 0,
|
|
"processing_time_seconds": 0,
|
|
"reset_performed": reset_index,
|
|
"message": "처리할 사용자가 없습니다."
|
|
}
|
|
|
|
self.logger.info(f"📊 전체 사용자 수: {total_users}명")
|
|
|
|
# 3. 기존 벡터 확인 (초기화한 경우 0개)
|
|
existing_vector_ids = []
|
|
existing_count = 0
|
|
|
|
if not reset_index:
|
|
existing_vector_ids = await self._get_existing_vector_ids()
|
|
existing_count = len(existing_vector_ids)
|
|
self.logger.info(f"📊 기존 벡터 수: {existing_count}개")
|
|
|
|
# 4. 처리 대상 사용자 결정
|
|
if reset_index:
|
|
# 초기화한 경우 모든 사용자 처리
|
|
users_to_process = all_users
|
|
new_users_count = total_users
|
|
self.logger.info(f"📊 초기화 후 전체 처리 대상: {new_users_count}명")
|
|
else:
|
|
# 초기화하지 않은 경우 신규 사용자만 처리
|
|
users_to_process = []
|
|
for user in all_users:
|
|
user_id = user.get("member_serial_number")
|
|
if str(user_id) not in existing_vector_ids:
|
|
users_to_process.append(user)
|
|
|
|
new_users_count = len(users_to_process)
|
|
self.logger.info(f"📊 신규 처리 대상: {new_users_count}명")
|
|
|
|
if new_users_count == 0:
|
|
self.logger.info("✅ 모든 사용자의 벡터가 이미 존재합니다.")
|
|
return {
|
|
"success": True,
|
|
"total_users": total_users,
|
|
"existing_vectors": existing_count,
|
|
"new_vectors": 0,
|
|
"failed": 0,
|
|
"processing_time_seconds": round(time.time() - start_time, 2),
|
|
"reset_performed": reset_index,
|
|
"message": "모든 사용자의 벡터가 이미 존재합니다."
|
|
}
|
|
|
|
# 5. 사용자 벡터 일괄 저장
|
|
success_count = 0
|
|
failed_count = 0
|
|
|
|
for i, user in enumerate(users_to_process, 1):
|
|
user_id = user.get("member_serial_number")
|
|
|
|
try:
|
|
# 진행률 로깅 (10개마다 또는 중요 지점)
|
|
if i % 10 == 0 or i == 1 or i == new_users_count:
|
|
progress = (i / new_users_count) * 100
|
|
self.logger.info(f"🔄 진행률: {i}/{new_users_count} ({progress:.1f}%) - "
|
|
f"현재 처리: user_id={user_id}")
|
|
|
|
# 개별 사용자 벡터 저장
|
|
success = await pinecone_client.upsert_user_vector(user_id, user)
|
|
|
|
if success:
|
|
success_count += 1
|
|
self.logger.debug(f"✅ 벡터 저장 성공 - user_id: {user_id}")
|
|
else:
|
|
failed_count += 1
|
|
self.logger.warning(f"❌ 벡터 저장 실패 - user_id: {user_id}")
|
|
|
|
# API 요청 간 짧은 대기 (Pinecone API 한도 고려)
|
|
if i % 5 == 0:
|
|
await self._short_delay(0.1)
|
|
|
|
except Exception as e:
|
|
failed_count += 1
|
|
self.logger.error(f"❌ 사용자 벡터 처리 실패 - user_id: {user_id}, error: {str(e)}")
|
|
continue
|
|
|
|
# 6. 최종 인덱스 통계 확인
|
|
final_stats = await pinecone_client.get_index_stats()
|
|
final_vector_count = final_stats.get('total_vector_count', 0)
|
|
|
|
# 7. 결과 정리
|
|
processing_time = round(time.time() - start_time, 2)
|
|
|
|
result = {
|
|
"success": failed_count == 0,
|
|
"total_users": total_users,
|
|
"existing_vectors": existing_count if not reset_index else 0,
|
|
"new_vectors": success_count,
|
|
"failed": failed_count,
|
|
"final_vector_count": final_vector_count,
|
|
"processing_time_seconds": processing_time,
|
|
"processed_users": new_users_count,
|
|
"success_rate": round((success_count / new_users_count) * 100, 1) if new_users_count > 0 else 100,
|
|
"reset_performed": reset_index
|
|
}
|
|
|
|
self.log_operation("upsert_all_user_vectors_complete",
|
|
total_users=total_users,
|
|
existing_vectors=existing_count,
|
|
new_vectors=success_count,
|
|
failed=failed_count,
|
|
reset_performed=reset_index,
|
|
processing_time=processing_time)
|
|
|
|
return result
|
|
|
|
except Exception as e:
|
|
self.logger.error(f"❌ 벡터 일괄 처리 전체 실패: {str(e)}")
|
|
raise DatabaseException(f"벡터 일괄 처리 실패: {str(e)}")
|
|
|
|
|
|
async def _get_all_users_for_vector_processing(self) -> List[Dict[str, Any]]:
|
|
"""벡터 처리를 위한 모든 사용자 데이터 조회"""
|
|
try:
|
|
all_users = await self.similar_mission_repository.get_all_users_for_vector()
|
|
|
|
self.logger.info(f"📊 사용자 데이터 조회 완료 - 총 {len(all_users)}명")
|
|
return all_users
|
|
|
|
except Exception as e:
|
|
self.logger.error(f"❌ 전체 사용자 조회 실패: {str(e)}")
|
|
raise DatabaseException(f"전체 사용자 조회 실패: {str(e)}")
|
|
|
|
|
|
async def _get_existing_vector_ids(self) -> List[str]:
|
|
"""Pinecone에서 기존 벡터 ID 목록 조회"""
|
|
try:
|
|
# Pinecone 초기화 확인
|
|
if not await pinecone_client.initialize():
|
|
self.logger.warning("⚠️ Pinecone 초기화 실패 - 기존 벡터 조회 건너뜀")
|
|
return []
|
|
|
|
# 인덱스 통계 조회
|
|
def get_index_stats():
|
|
return pinecone_client.index.describe_index_stats()
|
|
|
|
stats = await self._safe_async_execute(get_index_stats, timeout=10)
|
|
|
|
if not stats:
|
|
self.logger.warning("⚠️ 인덱스 통계 조회 실패")
|
|
return []
|
|
|
|
vector_count = stats.get('total_vector_count', 0)
|
|
self.logger.info(f"📊 Pinecone 인덱스 벡터 수: {vector_count}개")
|
|
|
|
# 모든 벡터 ID 조회 (대량 데이터의 경우 최적화 필요)
|
|
if vector_count == 0:
|
|
return []
|
|
|
|
# 임시로 빈 목록 반환 (실제로는 Pinecone list 기능 구현 필요)
|
|
# Pinecone은 모든 ID를 한번에 조회하는 기능이 제한적이므로
|
|
# 여기서는 간단히 빈 목록을 반환하여 모든 사용자를 처리하도록 함
|
|
return []
|
|
|
|
except Exception as e:
|
|
self.logger.warning(f"⚠️ 기존 벡터 ID 조회 실패: {str(e)}")
|
|
return []
|
|
|
|
|
|
async def _safe_async_execute(self, func, timeout: int = 10):
|
|
"""안전한 비동기 함수 실행"""
|
|
try:
|
|
import asyncio
|
|
return await asyncio.wait_for(
|
|
asyncio.get_event_loop().run_in_executor(None, func),
|
|
timeout=timeout
|
|
)
|
|
except Exception as e:
|
|
self.logger.warning(f"⚠️ 비동기 실행 실패: {str(e)}")
|
|
return None
|
|
|
|
|
|
async def _short_delay(self, seconds: float):
|
|
"""짧은 대기"""
|
|
try:
|
|
import asyncio
|
|
await asyncio.sleep(seconds)
|
|
except Exception:
|
|
pass |