commit 910bd902b1903f70ceb269b784df938e3da9ccfe Author: hyerimmy Date: Fri Jun 20 05:28:30 2025 +0000 feat : initial commit diff --git a/.env.sample b/.env.sample new file mode 100644 index 0000000..a03088c --- /dev/null +++ b/.env.sample @@ -0,0 +1,49 @@ +# .env.sample +# HealthSync AI 애플리케이션 설정 +APP_NAME=HealthSync AI +APP_VERSION=1.0.0 +DEBUG=True + +# 서버 설정 +HOST=localhost +PORT=8000 + +# 보안 설정 +SECRET_KEY=your-secret-key-here-change-in-production +ALGORITHM=HS256 +ACCESS_TOKEN_EXPIRE_MINUTES=30 + +# API 설정 +API_V1_PREFIX=/api +CORS_ORIGINS=["*"] + +# 로깅 설정 +LOG_LEVEL=INFO + +# Claude AI API 설정 +CLAUDE_API_KEY=your_claude_api_key_here +CLAUDE_MODEL=claude-3-sonnet-20240229 +CLAUDE_MAX_TOKENS=1500 +CLAUDE_TEMPERATURE=0.7 +CLAUDE_TIMEOUT=30 +CLAUDE_API_BASE_URL=https://api.anthropic.com + +# PostgreSQL 데이터베이스 설정 +DB_HOST=your_database_host_here +DB_PORT=5432 +DB_NAME=your_database_name_here +DB_USERNAME=your_database_username_here +DB_PASSWORD=your_database_password_here + +# Pinecone 벡터DB 설정 +PINECONE_API_KEY=your_pinecone_api_key_here +PINECONE_ENVIRONMENT=aped-4627-b74a +PINECONE_INDEX_NAME=healthsync-users + +# Azure Cache for Redis 설정 +# Azure Portal > Redis Cache > Access keys에서 확인 +REDIS_HOST=your-redis-cache-name.redis.cache.windows.net +REDIS_PORT=6380 +REDIS_PASSWORD=your_primary_access_key_here +REDIS_DB=0 +REDIS_SSL=True diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 0000000..23f5e08 --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,166 @@ +name: HealthSync Intelligence CI + +on: + push: + branches: [ main, develop ] + paths: + - '**/*.py' + - 'requirements.txt' + - 'Dockerfile' + - 'deployment/**' + - '.github/workflows/intelligence-ci.yml' + pull_request: + branches: [ main, develop ] + paths: + - '**/*.py' + - 'requirements.txt' + - 'Dockerfile' + - 'deployment/**' + workflow_dispatch: + inputs: + version: + description: '배포할 버전 (예: 1.2.3)' + required: false + type: string + +env: + ACR_REGISTRY: acrhealthsync01.azurecr.io + TEAM_ID: team1tier + PYTHON_VERSION: '3.11' + MANIFEST_REPO: TeamOneTier/HealthSync_Manifest + MANIFEST_BRANCH: main + +jobs: + build-and-push: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install Azure CLI + run: | + curl -sL https://aka.ms/InstallAzureCLIDeb | sudo bash + + - name: Azure 로그인 (Azure Login 액션 사용) + uses: azure/login@v1 + with: + creds: ${{ secrets.AZURE_CREDENTIALS }} + + - name: Show ACR Tags + run: | + az acr repository show-tags \ + --name acrhealthsync01 \ + --repository "${{ env.TEAM_ID }}/intelligence-service" + + - name: Generate version + id: version + run: | + if [[ "$GITHUB_EVENT_NAME" == "workflow_dispatch" && -n "${{ github.event.inputs.version }}" ]]; then + VERSION="${{ github.event.inputs.version }}" + echo "🎯 Using manual version: $VERSION" + else + echo "🔍 Generating version automatically from ACR..." + + REPO_NAME="${{ env.TEAM_ID }}/intelligence-service" + ACR_NAME="$(echo "${{ env.ACR_REGISTRY }}" | cut -d'.' -f1)" + + if TAGS=$(az acr repository show-tags --name "$ACR_NAME" --repository "$REPO_NAME" --output tsv 2>/dev/null); then + if [[ -z "$TAGS" ]]; then + VERSION="1.0.0" + else + LATEST_VERSION=$(echo "$TAGS" | tr '\t' '\n' | grep -E '^[0-9]+\.[0-9]+\.[0-9]+$' | sort -t. -k1,1n -k2,2n -k3,3n | tail -1) + if [[ -z "$LATEST_VERSION" ]]; then + VERSION="1.0.0" + else + MAJOR=$(echo "$LATEST_VERSION" | cut -d'.' -f1) + MINOR=$(echo "$LATEST_VERSION" | cut -d'.' -f2) + PATCH=$(echo "$LATEST_VERSION" | cut -d'.' -f3) + VERSION="$MAJOR.$MINOR.$((PATCH + 1))" + fi + fi + else + VERSION="1.0.0" + fi + fi + + echo "version=$VERSION" >> $GITHUB_OUTPUT + echo "🏷️ Final version for Intelligence: $VERSION" + + - name: Set up Python ${{ env.PYTHON_VERSION }} + uses: actions/setup-python@v4 + with: + python-version: ${{ env.PYTHON_VERSION }} + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + if [ -f requirements.txt ]; then + pip install -r requirements.txt + echo "✅ Python dependencies installed" + fi + + - name: Login to ACR + run: | + echo ${{ secrets.ACR_PASSWORD }} | docker login ${{ env.ACR_REGISTRY }} \ + --username ${{ secrets.ACR_USERNAME }} \ + --password-stdin + + - name: Build and Push Docker Image + run: | + IMAGE_TAG="${{ env.ACR_REGISTRY }}/${{ env.TEAM_ID }}/intelligence-service:${{ steps.version.outputs.version }}" + + docker build \ + --platform linux/amd64 \ + --build-arg SERVICE_NAME=healthsync-intelligence \ + --build-arg VERSION=${{ steps.version.outputs.version }} \ + -t "$IMAGE_TAG" \ + . + + docker push "$IMAGE_TAG" + echo "✅ Pushed: $IMAGE_TAG" + + - name: Checkout Manifest Repository + uses: actions/checkout@v4 + with: + repository: ${{ env.MANIFEST_REPO }} + token: ${{ secrets.GIT_REPO_TOKEN }} + path: manifest-repo + + - name: Update Manifest Deployment + run: | + cd manifest-repo + + # Intelligence manifest 파일 경로 설정 + MANIFEST_FILE="HealthSync_Intelligence/manifest/deployment/intelligence-service-deployment.yaml" + + if [ ! -f "$MANIFEST_FILE" ]; then + echo "❌ Manifest file not found: $MANIFEST_FILE" + exit 1 + fi + + # 새로운 이미지 태그 + NEW_IMAGE="${{ env.ACR_REGISTRY }}/${{ env.TEAM_ID }}/intelligence-service:${{ steps.version.outputs.version }}" + + echo "🔄 Updating $MANIFEST_FILE with new image: $NEW_IMAGE" + + # sed를 사용하여 image 태그 업데이트 + sed -i "s|image: ${{ env.ACR_REGISTRY }}/${{ env.TEAM_ID }}/intelligence-service:.*|image: $NEW_IMAGE|g" "$MANIFEST_FILE" + + echo "✅ Updated manifest file" + git diff "$MANIFEST_FILE" + + - name: Commit and Push Manifest Changes + run: | + cd manifest-repo + + git config --global user.name "github-actions[bot]" + git config --global user.email "github-actions[bot]@users.noreply.github.com" + + git add . + git commit -m "🚀 Update intelligence-service image to ${{ steps.version.outputs.version }}" || { + echo "ℹ️ No changes to commit" + exit 0 + } + + git push origin ${{ env.MANIFEST_BRANCH }} + echo "✅ Manifest updated and pushed successfully" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b3dacee --- /dev/null +++ b/.gitignore @@ -0,0 +1,56 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Virtual Environment +venv/ +env/ +ENV/ +.venv/ + +# Environment variables +.env +.env.local +.env.production + +# IDE +.vscode/ +.idea/ +*.swp +*.swo + +# Logs +*.log +logs/ + +# Database +*.db +*.sqlite3 +healthsync_ai.db + +# OS +.DS_Store +Thumbs.db + +# HealthSync AI specific +temp/ +uploads/ +cache/ diff --git a/=7.0.0 b/=7.0.0 new file mode 100644 index 0000000..dd3a75f --- /dev/null +++ b/=7.0.0 @@ -0,0 +1,16 @@ +Collecting pinecone + Downloading pinecone-7.1.0-py3-none-any.whl.metadata (9.5 kB) +Requirement already satisfied: certifi>=2019.11.17 in c:\home\workspace\healthsync_intelligence\.venv\lib\site-packages (from pinecone) (2025.6.15) +Requirement already satisfied: pinecone-plugin-assistant<2.0.0,>=1.6.0 in c:\home\workspace\healthsync_intelligence\.venv\lib\site-packages (from pinecone) (1.7.0) +Requirement already satisfied: pinecone-plugin-interface<0.0.8,>=0.0.7 in c:\home\workspace\healthsync_intelligence\.venv\lib\site-packages (from pinecone) (0.0.7) +Requirement already satisfied: python-dateutil>=2.5.3 in c:\home\workspace\healthsync_intelligence\.venv\lib\site-packages (from pinecone) (2.9.0.post0) +Requirement already satisfied: typing-extensions>=3.7.4 in c:\home\workspace\healthsync_intelligence\.venv\lib\site-packages (from pinecone) (4.14.0) +Requirement already satisfied: urllib3>=1.26.5 in c:\home\workspace\healthsync_intelligence\.venv\lib\site-packages (from pinecone) (2.4.0) +Requirement already satisfied: packaging<25.0,>=24.2 in c:\home\workspace\healthsync_intelligence\.venv\lib\site-packages (from pinecone-plugin-assistant<2.0.0,>=1.6.0->pinecone) (24.2) +Requirement already satisfied: requests<3.0.0,>=2.32.3 in c:\home\workspace\healthsync_intelligence\.venv\lib\site-packages (from pinecone-plugin-assistant<2.0.0,>=1.6.0->pinecone) (2.32.4) +Requirement already satisfied: charset_normalizer<4,>=2 in c:\home\workspace\healthsync_intelligence\.venv\lib\site-packages (from requests<3.0.0,>=2.32.3->pinecone-plugin-assistant<2.0.0,>=1.6.0->pinecone) (3.4.2) +Requirement already satisfied: idna<4,>=2.5 in c:\home\workspace\healthsync_intelligence\.venv\lib\site-packages (from requests<3.0.0,>=2.32.3->pinecone-plugin-assistant<2.0.0,>=1.6.0->pinecone) (3.10) +Requirement already satisfied: six>=1.5 in c:\home\workspace\healthsync_intelligence\.venv\lib\site-packages (from python-dateutil>=2.5.3->pinecone) (1.17.0) +Downloading pinecone-7.1.0-py3-none-any.whl (517 kB) +Installing collected packages: pinecone +Successfully installed pinecone-7.1.0 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..f6a9414 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,40 @@ +# Intelligence Service Dockerfile +# Python FastAPI 기반 AI 건강관리 서비스 + +FROM python:3.11-slim + +# 작업 디렉토리 설정 +WORKDIR /app + +# 시스템 패키지 업데이트 및 필요한 패키지 설치 +RUN apt-get update && apt-get install -y \ + build-essential \ + curl \ + && rm -rf /var/lib/apt/lists/* + +# Python 의존성 파일 복사 및 설치 +COPY requirements.txt . +RUN pip install --no-cache-dir --upgrade pip +RUN pip install --no-cache-dir -r requirements.txt + +# 애플리케이션 코드 복사 +COPY . . + +# 비-root 사용자 생성 및 권한 설정 +RUN groupadd -r appuser && useradd -r -g appuser appuser +RUN chown -R appuser:appuser /app +USER appuser + +# 헬스체크 설정 +HEALTHCHECK --interval=30s --timeout=30s --start-period=5s --retries=3 \ + CMD curl -f http://localhost:8083/api/v1/health/status || exit 1 + +# 포트 노출 +EXPOSE 8083 + +# 환경변수 설정 +ENV PYTHONPATH=/app +ENV PYTHONUNBUFFERED=1 + +# 애플리케이션 실행 (프로젝트 표준 방식 사용) +CMD ["python", "run.py"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..22626f6 --- /dev/null +++ b/README.md @@ -0,0 +1,50 @@ +# HealthSync AI + +AI 기반 개인 맞춤형 건강관리 서비스 - FastAPI MVC 패턴 백엔드 + +## 🏥 서비스 개요 + +HealthSync AI는 개인의 건강 데이터를 분석하여 맞춤형 건강 관리 솔루션을 제공하는 AI 기반 플랫폼입니다. + +## 🏗️ MVC 패턴 아키텍처 + +``` +📁 HealthSync_AI/ +├── 📊 Model (app/models/): 데이터 구조 정의 +├── 🌐 View (app/views/): API 라우팅 관리 +├── 🎮 Controller (app/controllers/): 요청/응답 처리 +├── ⚙️ Service (app/services/): 비즈니스 로직 +├── 🔧 Core (app/core/): 공통 기능 +├── ⚙️ Config (app/config/): 설정 관리 +└── 🛠️ Utils (app/utils/): 유틸리티 +``` + +## 🚀 빠른 시작 +```bash +# 의존성 설치 +pip install -r requirements.txt + +# 서버 실행 +python run.py +``` + +## 🧪 테스트 실행 + +```bash +pytest tests/ +``` + +## 📝 개발 가이드 + +새로운 기능 추가 시: +1. **Model**: `app/models/` - 데이터 구조 정의 +2. **Service**: `app/services/` - 비즈니스 로직 구현 +3. **Controller**: `app/controllers/` - API 로직 작성 +4. **View**: `app/views/` - 라우터 등록 + +## 📄 라이선스 + +MIT License + +--- +**Built with ❤️ for Healthcare** | **HealthSync AI Team** diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..b482c01 --- /dev/null +++ b/app/__init__.py @@ -0,0 +1,6 @@ +""" +HealthSync AI - AI 기반 개인 맞춤형 건강관리 서비스 +""" +__version__ = "1.0.0" +__title__ = "HealthSync AI" +__description__ = "AI 기반 개인 맞춤형 건강관리 서비스" diff --git a/app/config/__init__.py b/app/config/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/config/prompts.py b/app/config/prompts.py new file mode 100644 index 0000000..b5a7765 --- /dev/null +++ b/app/config/prompts.py @@ -0,0 +1,173 @@ +# app/config/prompts.py +""" +HealthSync AI 프롬프트 설정 관리 +""" + + +class PromptConfig: + """AI 프롬프트 템플릿 설정""" + + MISSION_RECOMMENDATION_PROMPT = """ +당신은 건강 코치입니다. 다음 정보를 바탕으로 건강 미션 5개를 JSON 형식으로 추천해주세요. + +**미션 추천 원칙:** +- 일일 목표 횟수는 1~5 범위 +- 사용자가 능동적으로 실행 가능한 일상 건강 행동 +- 외부 상황에 의존하지 않는 미션 (예: 물 마시기, 스트레칭, 걷기) +- 직업 특성을 고려한 맞춤형 추천 + +**사용자 정보:** +- 직업: {occupation} (이 직업군의 건강 리스크를 고려하여 추천) +- 나이: {age}세, 신장: {height}cm, 체중: {weight}kg +- 허리둘레: {waist_circumference}cm + +**주요 건강 지표:** +- 혈압: {systolic_bp}/{diastolic_bp} mmHg +- 공복혈당: {fasting_glucose} mg/dL +- 콜레스테롤: 총 {total_cholesterol}, HDL {hdl_cholesterol}, LDL {ldl_cholesterol} mg/dL +- 간수치: AST {ast}, ALT {alt} IU/L +- 생활습관: 흡연 {smoking_status}, 음주 {drinking_status} + +다음 JSON 형식으로 응답하세요: +{{ + "missions": [ + {{ + "title": "미션 실행 방법", + "daily_target_count": 3, + "reason": "추천 이유 1줄" + }} + ] +}} +""" + + HEALTH_DIAGNOSIS_PROMPT = """ +당신은 건강검진 데이터를 분석하는 전문 의료 AI입니다. +아래 건강검진 데이터를 바탕으로 짧은 2문장으로 건강 상태를 요약 진단해주세요. +글자수는 꼭 100자 이내로 작성해주세요. +사용자의 이름을 활용하여 작성해주세요. + +**분석 원칙:** +- 객관적이고 정확한 의학적 판단 +- 개선이 필요한 부분과 긍정적인 부분 균형 있게 제시 +- 직업({occupation})을 고려한 맞춤형 조언 + +**사용자 정보:** +- 이름: {name} +- 직업: {occupation} +- 나이: {age}세 +- 신체: 신장 {height}cm, 체중 {weight}kg, BMI {bmi}, 허리둘레 {waist_circumference}cm + +**주요 건강 지표:** +- 혈압: 수축기 {systolic_bp}mmHg, 이완기 {diastolic_bp}mmHg +- 혈당: 공복혈당 {fasting_glucose}mg/dL +- 콜레스테롤: 총 {total_cholesterol}mg/dL, HDL {hdl_cholesterol}mg/dL, LDL {ldl_cholesterol}mg/dL, 중성지방 {triglyceride}mg/dL +- 간기능: AST {ast}IU/L, ALT {alt}IU/L, 감마지티피 {gamma_gtp}IU/L +- 신기능: 혈청크레아티닌 {serum_creatinine}mg/dL, 요단백 {urine_protein} +- 혈액: 혈색소 {hemoglobin}g/dL +- 감각기관: 좌측시력 {visual_acuity_left}, 우측시력 {visual_acuity_right}, 좌측청력 {hearing_left}, 우측청력 {hearing_right} +- 생활습관: 흡연 {smoking_status}, 음주 {drinking_status} + +**응답 형식:** +정확히 3문장으로 작성하되, 각 문장은 마침표(.)로 끝나야 합니다. +문장 사이는 공백 하나로 구분합니다. +""" + + CHAT_CONSULTATION_PROMPT = """ +당신은 HealthSync AI의 전문 건강 상담 AI입니다. 사용자의 건강검진 데이터를 바탕으로 개인화된 건강 상담을 제공합니다. + +**상담 원칙:** +- 개인 건강 데이터를 기반으로 맞춤형 답변 제공 +- 의학적 근거에 기반한 정확하고 신뢰할 수 있는 정보 +- 직업 특성을 고려한 실질적인 건강 관리 방법 제시 +- 친근하고 이해하기 쉬운 언어로 설명 +- 심각한 증상 시 반드시 의료진 상담 권유 + +**사용자 질문:** +{user_question} + +**사용자 건강 정보:** +- 이름: {name} +- 직업: {occupation} (직업 특성을 고려한 조언 제공) +- 나이: {age}세 + +**신체 정보:** +- 신장: {height}cm, 체중: {weight}kg, BMI: {bmi} +- 허리둘레: {waist_circumference}cm + +**혈압 및 혈당:** +- 혈압: 수축기 {systolic_bp}mmHg, 이완기 {diastolic_bp}mmHg +- 공복혈당: {fasting_glucose}mg/dL + +**콜레스테롤:** +- 총콜레스테롤: {total_cholesterol}mg/dL +- HDL콜레스테롤: {hdl_cholesterol}mg/dL +- LDL콜레스테롤: {ldl_cholesterol}mg/dL +- 중성지방: {triglyceride}mg/dL + +**간기능 및 신기능:** +- AST: {ast}IU/L, ALT: {alt}IU/L, 감마지티피: {gamma_gtp}IU/L +- 혈청크레아티닌: {serum_creatinine}mg/dL, 요단백: {urine_protein} +- 혈색소: {hemoglobin}g/dL + +**감각기관:** +- 시력: 좌측 {visual_acuity_left}, 우측 {visual_acuity_right} +- 청력: 좌측 {hearing_left}, 우측 {hearing_right} + +**생활습관:** +- 흡연: {smoking_status}, 음주: {drinking_status} + +**응답 요구사항:** +1. 인사는 생략하며, 사용자의 현재 건강 상태와 질문 내용을 연관지어 답변 +2. 구체적이고 실행 가능한 건강 관리 방법 제시 +3. 직업 특성({occupation})을 고려한 맞춤형 조언 +4. 150자 내의 적절한 길이로 답변 +5. 필요시 의료진 상담 권유 포함 + +위 정보를 바탕으로 사용자의 질문에 대해 전문적이고 개인화된 건강 상담을 제공해주세요. +""" + + CELEBRATION_PROMPT = """ +당신은 건강 미션을 달성한 사용자를 축하하는 따뜻한 AI 코치입니다. + +**축하 메시지 작성 원칙:** +- 정확히 1줄로 작성 (50자 내외) +- 따뜻하고 격려적인 톤 +- 적절한 이모지 2-3개 사용 +- 미션 달성에 대한 구체적인 축하 +- 지속적인 동기부여 메시지 포함 +- 미션의 구체적인 내용을 반영 + +**달성한 미션 정보:** +- 미션명: {mission_name} +- 미션 설명: {mission_description} +- 일일 목표: {daily_target_count}회/일 +- 사용자 ID: {user_id} + +위 미션을 달성한 사용자에게 미션의 구체적인 내용을 반영한 따뜻한 축하 메시지 1줄을 작성해주세요. +미션명과 설명을 참고하여 더욱 개인화된 축하 메시지를 만들어주세요. +메시지만 작성하고 다른 말은 하지 마세요. + +예시: +🎉 30분 걷기 완료! 건강한 습관을 만들어가는 당신이 자랑스러워요! 💪✨ +🌟 물 8잔 마시기 성공! 몸이 기뻐하는 소리가 들려요! 💧😊 +🧘‍♀️ 명상 완료! 마음이 한결 평온해졌을 거예요! ✨🙏 +🚶‍♀️ 계단 오르기 미션 달성! 작은 실천이 큰 변화를 만들어요! 🏃‍♂️⭐ +""" + +def get_mission_recommendation_prompt() -> str: + """미션 추천 프롬프트 템플릿 반환""" + return PromptConfig.MISSION_RECOMMENDATION_PROMPT + + +def get_health_diagnosis_prompt() -> str: + """건강 진단 프롬프트 템플릿 반환""" + return PromptConfig.HEALTH_DIAGNOSIS_PROMPT + + +def get_chat_consultation_prompt() -> str: + """챗봇 상담 프롬프트 템플릿 반환""" + return PromptConfig.CHAT_CONSULTATION_PROMPT + +def get_celebration_prompt() -> str: + """미션 축하 프롬프트 템플릿 반환""" + return PromptConfig.CELEBRATION_PROMPT \ No newline at end of file diff --git a/app/config/settings.py b/app/config/settings.py new file mode 100644 index 0000000..4659695 --- /dev/null +++ b/app/config/settings.py @@ -0,0 +1,94 @@ +# app/config/settings.py +""" +HealthSync AI 애플리케이션 설정 관리 (올바른 Pinecone 설정) +""" +from pydantic_settings import BaseSettings +from typing import List, Optional +import os +from dotenv import load_dotenv + +# .env 파일 로드 +load_dotenv() + +class Settings(BaseSettings): + """HealthSync AI 애플리케이션 설정 클래스""" + + # 기본 앱 설정 + app_name: str = "HealthSync AI" + app_version: str = "1.0.0" + debug: bool = True + app_description: str = "AI 기반 개인 맞춤형 건강관리 서비스" + + # 서버 설정 + host: str = "localhost" + port: int = 8000 + + # 보안 설정 + secret_key: str = "healthsync-ai-secret-key-change-in-production" + algorithm: str = "HS256" + access_token_expire_minutes: int = 30 + + # API 설정 + api_v1_prefix: str = "/api" + cors_origins: List[str] = ["*"] + + # PostgreSQL 설정 + db_host: str = "localhost" + db_port: int = 5432 + db_name: str = "healthsync_ai" + db_username: str = "postgres" + db_password: str = "password" + db_min_size: int = 1 + db_max_size: int = 10 + + # Claude AI 설정 + claude_api_key: str = "" + claude_model: str = "claude-3-5-sonnet-20241022" + claude_max_tokens: int = 1500 + claude_temperature: float = 0.7 + claude_timeout: int = 30 + claude_api_base_url: str = "https://api.anthropic.com" + + # Pinecone 벡터DB 설정 (올바른 기본값) + pinecone_api_key: str = "" + pinecone_environment: str = "aped-4627-b74a" # 실제 환경 + pinecone_index_name: str = "healthsync-users" + + # Azure Cache for Redis 설정 + redis_host: str = "localhost" + redis_port: int = 6380 # Azure Cache for Redis 기본 SSL 포트 + redis_password: str = "" # Azure Cache Primary Access Key + redis_db: int = 0 + redis_cache_ttl: int = 1800 # 30분 + redis_ssl: bool = True # Azure Cache는 SSL 필수 + + # 로깅 설정 + log_level: str = "INFO" + + @property + def database_url(self) -> str: + """데이터베이스 URL 생성""" + return f"postgresql://{self.db_username}:{self.db_password}@{self.db_host}:{self.db_port}/{self.db_name}" + + @property + def redis_url(self) -> str: + """Redis URL 생성 (Azure Cache for Redis 지원)""" + if self.redis_password: + # Azure Cache for Redis (SSL + 인증) + protocol = "rediss" if self.redis_ssl else "redis" + return f"{protocol}://:{self.redis_password}@{self.redis_host}:{self.redis_port}/{self.redis_db}" + else: + # 로컬 Redis (비SSL) + return f"redis://{self.redis_host}:{self.redis_port}/{self.redis_db}" + + @property + def is_azure_redis(self) -> bool: + """Azure Cache for Redis 사용 여부 확인""" + return bool(self.redis_password and "cache.windows.net" in self.redis_host) + + class Config: + env_file = ".env" + case_sensitive = False + +# 전역 설정 인스턴스 +settings = Settings() \ No newline at end of file diff --git a/app/controllers/__init__.py b/app/controllers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/controllers/base_controller.py b/app/controllers/base_controller.py new file mode 100644 index 0000000..1547e43 --- /dev/null +++ b/app/controllers/base_controller.py @@ -0,0 +1,60 @@ +""" +HealthSync AI 기본 컨트롤러 클래스 +""" +from fastapi import HTTPException, status +from typing import Any + +from fastapi.params import Depends + +from app.core.dependencies import get_settings +from app.models.base import BaseResponse, ErrorResponse +from app.config.settings import settings, Settings +import logging +from datetime import datetime + +class BaseController: + """기본 컨트롤러 클래스""" + + def __init__(self): + self.settings = settings + self.logger = logging.getLogger(self.__class__.__name__) + + def create_success_response(self, data: Any = None, message: str = "성공적으로 처리되었습니다.") -> BaseResponse: + """성공 응답 생성""" + return BaseResponse(success=True, message=message, data=data, timestamp=datetime.now()) + + def handle_service_error(self, error: Exception, operation: str = "unknown"): + """서비스 에러 처리""" + self.logger.error(f"Service error in {operation}: {str(error)}", exc_info=True) + + if isinstance(error, ValueError): + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(error)) + else: + raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="서버 내부 오류가 발생했습니다.") + + def log_request(self, endpoint: str, user_id: int = None, **kwargs): + """요청 로그 기록""" + log_data = {"endpoint": endpoint, "controller": self.__class__.__name__, "timestamp": datetime.now().isoformat()} + if user_id: + log_data["user_id"] = user_id + log_data.update(kwargs) + self.logger.info(f"Request to {endpoint}", extra=log_data) + + def handle_service_error(self, error: Exception, operation: str = "unknown"): + """서비스 에러 처리""" + self.logger.error(f"Service error in {operation}: {str(error)}", exc_info=True) + + if isinstance(error, ValueError): + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(error)) + elif "찾을 수 없습니다" in str(error) or "없음" in str(error): + # 데이터 조회 실패인 경우 404로 처리 + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(error)) + elif "Claude API" in str(error) or "AI" in str(error): + # AI 서비스 오류인 경우 구체적 메시지 전달 + raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=f"AI 서비스 오류: {str(error)}") + elif "데이터베이스" in str(error) or "쿼리" in str(error): + # 데이터베이스 오류인 경우 + raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=f"데이터베이스 오류: {str(error)}") + else: + # 기타 오류는 구체적 메시지와 함께 500으로 처리 + raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(error)) \ No newline at end of file diff --git a/app/controllers/chat_controller.py b/app/controllers/chat_controller.py new file mode 100644 index 0000000..23f77a2 --- /dev/null +++ b/app/controllers/chat_controller.py @@ -0,0 +1,124 @@ +# app/controllers/chat_controller.py +""" +HealthSync AI 챗봇 상담 컨트롤러 +""" +from fastapi import APIRouter, status, HTTPException, Query +from app.controllers.base_controller import BaseController +from app.services.chat_service import ChatService +from app.dto.request.chat_request import ChatRequest +from app.dto.response.chat_response import ChatResponse +from app.dto.response.chat_history_response import ChatHistoryResponse + + +class ChatController(BaseController): + """챗봇 상담 관련 컨트롤러""" + + def __init__(self): + super().__init__() + self.chat_service = ChatService() + self.router = APIRouter() + self._setup_routes() + + def _setup_routes(self): + """라우트 설정""" + + @self.router.post("/consultation", + response_model=ChatResponse, + status_code=status.HTTP_200_OK, + summary="💬 AI 건강 상담 챗봇", + description=""" +사용자의 건강검진 데이터를 기반으로 AI가 개인화된 건강 상담을 제공합니다. + +**처리 과정:** +1. 사용자 질문 데이터베이스에 저장 +2. 사용자 기본 정보 조회 (직업, 나이 등) +3. 최신 건강검진 데이터 조회 +4. 사용자 질문과 건강 데이터를 Claude AI로 분석 +5. 맞춤형 건강 상담 답변 생성 +6. 질문에 대한 응답 내용을 데이터베이스에 저장 + +**상담 특징:** +- 개인 건강 데이터 기반 맞춤형 답변 +- 직업 특성을 고려한 건강 조언 +- 의학적 근거에 기반한 정확한 정보 제공 +- 실질적이고 실행 가능한 건강 관리 방법 제시 +- 모든 상담 내용 자동 저장 및 이력 관리 + +**주의사항:** +- 이 서비스는 의학적 진단이나 치료를 대체하지 않습니다 +- 심각한 증상이 있을 경우 반드시 의료진과 상담하세요 + """) + async def health_consultation(request: ChatRequest) -> ChatResponse: + """AI 기반 건강 상담 챗봇 (DB 저장 포함)""" + try: + self.log_request("health_consultation", user_id=request.user_id, + message_preview=request.message[:50] + "..." if len(request.message) > 50 else request.message) + + # 챗봇 상담 서비스 호출 (DB 저장 포함) + response = await self.chat_service.get_health_consultation( + user_id=request.user_id, + message=request.message + ) + + self.logger.info(f"건강 상담 성공 - user_id: {request.user_id}, " + f"질문 길이: {len(request.message)}, 답변 길이: {len(response.response)}") + + return response + + except ValueError as e: + self.logger.warning(f"잘못된 요청 - user_id: {request.user_id}, error: {str(e)}") + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"잘못된 요청입니다: {str(e)}" + ) + except Exception as e: + self.handle_service_error(e, "health_consultation") + + @self.router.get("/history", + response_model=ChatHistoryResponse, + status_code=status.HTTP_200_OK, + summary="📋 채팅 히스토리 조회", + description=""" +사용자의 모든 채팅 기록을 조회합니다. + +**처리 과정:** +1. 사용자 ID로 데이터베이스에서 채팅 기록 조회 +2. 시간 역순으로 정렬하여 반환 +3. 질문과 응답을 모두 포함한 전체 이력 제공 + +**응답 특징:** +- 최신 채팅이 먼저 표시됨 (시간 역순) +- 페이지네이션 없이 전체 기록 반환 +- 메시지 타입별 구분 (상담, 축하, 독려 등) +- 각 채팅의 고유 ID와 생성 시간 포함 + +**활용 방안:** +- 이전 상담 내용 참고 +- 건강 관리 진행 상황 확인 +- AI 응답 품질 개선을 위한 피드백 수집 + """) + async def get_chat_history(user_id: int = Query(..., gt=0, description="사용자 ID")) -> ChatHistoryResponse: + """사용자 채팅 히스토리 조회""" + try: + self.log_request("get_chat_history", user_id=user_id) + + # 채팅 이력 서비스 호출 + response = await self.chat_service.get_chat_history(user_id) + + self.logger.info(f"채팅 이력 조회 성공 - user_id: {user_id}, " + f"총 채팅 수: {response.total_count}") + + return response + + except ValueError as e: + self.logger.warning(f"잘못된 요청 - user_id: {user_id}, error: {str(e)}") + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"잘못된 요청입니다: {str(e)}" + ) + except Exception as e: + self.handle_service_error(e, "get_chat_history") + + +# 컨트롤러 인스턴스 생성 +chat_controller = ChatController() \ No newline at end of file diff --git a/app/controllers/health_controller.py b/app/controllers/health_controller.py new file mode 100644 index 0000000..9c1047e --- /dev/null +++ b/app/controllers/health_controller.py @@ -0,0 +1,70 @@ +# app/controllers/health_controller.py +""" +HealthSync AI 건강 분석 컨트롤러 +""" +from fastapi import APIRouter, status, HTTPException, Query +from app.controllers.base_controller import BaseController +from app.services.health_service import HealthService +from app.dto.response.health_response import HealthDiagnosisResponse + + +class HealthController(BaseController): + """건강 분석 관련 컨트롤러""" + + def __init__(self): + super().__init__() + self.health_service = HealthService() + self.router = APIRouter() + self._setup_routes() + + def _setup_routes(self): + """라우트 설정""" + + @self.router.get("/diagnosis", + response_model=HealthDiagnosisResponse, + status_code=status.HTTP_200_OK, + summary="🔬 AI 건강검진 3줄 요약 진단", + description=""" +사용자의 건강검진 데이터를 기반으로 AI가 3줄로 요약 진단을 제공합니다. + +**처리 과정:** +1. 사용자 기본 정보 조회 (직업, 나이 등) +2. 최신 건강검진 데이터 조회 +3. Claude AI를 통한 의학적 분석 +4. 3문장으로 구성된 요약 진단 반환 + +**진단 특징:** +- 객관적이고 정확한 의학적 판단 +- 가장 중요한 건강 지표 우선 분석 +- 직업 특성을 고려한 맞춤형 조언 +- 개선점과 긍정적 요소의 균형 있는 제시 + """) + async def get_health_diagnosis(user_id: int = Query(..., gt=0, description="사용자 ID")) -> HealthDiagnosisResponse: + """AI 기반 건강검진 3줄 요약 진단""" + try: + self.log_request("get_health_diagnosis", user_id=user_id) + + # 건강 진단 서비스 호출 + diagnosis = await self.health_service.get_three_sentence_diagnosis(user_id) + + # 응답 생성 + response = HealthDiagnosisResponse(threeSentenceSummary=diagnosis) + + self.logger.info(f"건강 진단 성공 - user_id: {user_id}, " + f"진단 길이: {len(diagnosis)} 문자") + + return response + + except ValueError as e: + # 사용자나 데이터를 찾을 수 없는 경우 + self.logger.warning(f"데이터 없음 - user_id: {user_id}, error: {str(e)}") + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=str(e) + ) + except Exception as e: + self.handle_service_error(e, "get_health_diagnosis") + + +# 컨트롤러 인스턴스 생성 +health_controller = HealthController() \ No newline at end of file diff --git a/app/controllers/mission_controller.py b/app/controllers/mission_controller.py new file mode 100644 index 0000000..9a498a1 --- /dev/null +++ b/app/controllers/mission_controller.py @@ -0,0 +1,295 @@ +# app/controllers/mission_controller.py +""" +HealthSync AI 미션 관련 컨트롤러 (다층 특성 기반 AI 이모지 자동 매핑) +""" +from fastapi import APIRouter, status, HTTPException, Query +from app.controllers.base_controller import BaseController +from app.services.mission_service import MissionService +from app.dto.request.mission_request import MissionRecommendRequest +from app.dto.response.mission_response import MissionRecommendationResponse +from app.dto.request.celebration_request import CelebrationRequest +from app.dto.response.celebration_response import CelebrationResponse +from app.dto.response.similar_mission_news_response import SimilarMissionNewsResponse +from app.exceptions import ( + UserNotFoundException, + HealthDataNotFoundException, + DatabaseException, + ClaudeAPIException +) + + +class MissionController(BaseController): + """미션 관련 컨트롤러 (다층 특성 기반 AI 이모지 자동 매핑)""" + + def __init__(self): + super().__init__() + self.mission_service = MissionService() + self.router = APIRouter() + self._setup_routes() + + def _setup_routes(self): + """라우트 설정""" + + @self.router.post("/recommend", + response_model=MissionRecommendationResponse, + status_code=status.HTTP_200_OK, + summary="🎯 AI 건강 미션 추천", + description=""" +사용자의 건강검진 데이터를 기반으로 AI가 맞춤형 건강 미션을 추천합니다. + +**처리 과정:** +1. 사용자 기본 정보 조회 (직업, 나이 등) +2. 최신 건강검진 데이터 조회 +3. Claude AI를 통한 개인화된 미션 생성 +4. 미션별 추천 이유와 함께 반환 + +**추천 미션 특징:** +- 일일 목표 횟수 1-5회 범위 +- 사용자 건강 상태에 맞춤화 +- 일상에서 실행 가능한 건강 행동 +- 각 미션별 상세한 추천 이유 제공 + """) + async def recommend_missions(request: MissionRecommendRequest) -> MissionRecommendationResponse: + """AI 기반 건강 미션 추천""" + try: + self.log_request("recommend_missions", user_id=request.user_id) + + # 미션 추천 서비스 호출 + response = await self.mission_service.recommend_missions(request.user_id) + + self.logger.info(f"미션 추천 성공 - user_id: {request.user_id}, " + f"추천 미션 수: {len(response.missions)}") + + return response + + except ValueError as e: + self.logger.warning(f"잘못된 요청 - user_id: {request.user_id}, error: {str(e)}") + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"잘못된 요청입니다: {str(e)}" + ) + except Exception as e: + self.handle_service_error(e, "recommend_missions") + + @self.router.post("/celebrate", + response_model=CelebrationResponse, + status_code=status.HTTP_200_OK, + summary="🎉 미션 달성 축하 메시지", + description=""" +사용자가 달성한 미션에 대해 AI가 개인화된 축하 메시지를 생성합니다. + +**처리 과정:** +1. 미션 ID(숫자)로 데이터베이스에서 미션 정보 조회 (미션명, 설명, 목표 등) +2. 조회된 미션 정보를 기반으로 Claude AI 호출 +3. 미션 내용을 반영한 맞춤형 축하 메시지 생성 +4. 생성된 축하 메시지를 Chat DB에 "celebration" 타입으로 저장 +5. 이모지와 함께 1줄 축하 메시지 반환 + +**축하 메시지 특징:** +- DB에서 조회한 실제 미션 정보 반영 +- 간결하고 따뜻한 1줄 메시지 (50자 내외) +- 다양한 이모지로 시각적 효과 +- 미션별 맞춤형 축하 내용 +- 지속적인 동기부여 유도 + """) + async def celebrate_mission(request: CelebrationRequest) -> CelebrationResponse: + """미션 달성 축하 메시지 생성 및 Chat DB 저장""" + try: + self.log_request("celebrate_mission", + user_id=request.userId, mission_id=request.missionId) + + # 축하 메시지 서비스 호출 (Chat DB 저장 포함) + response = await self.mission_service.generate_celebration_message( + user_id=request.userId, + mission_id=request.missionId + ) + + self.logger.info(f"미션 축하 성공 (Chat DB 저장 완료) - user_id: {request.userId}, " + f"mission_id: {request.missionId}, " + f"메시지 길이: {len(response.congratsMessage)}") + + return response + + except HealthDataNotFoundException as e: + self.logger.warning(f"미션을 찾을 수 없음 - mission_id: {request.missionId}") + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"미션 ID {request.missionId}를 찾을 수 없습니다." + ) + except DatabaseException as e: + self.logger.error(f"데이터베이스 오류 - error: {str(e)}") + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="데이터베이스 연결에 문제가 있습니다. 잠시 후 다시 시도해 주세요." + ) + except ClaudeAPIException as e: + self.logger.error(f"Claude API 오류 - error: {str(e)}") + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail="AI 서비스에 일시적인 문제가 있습니다. 잠시 후 다시 시도해 주세요." + ) + except Exception as e: + self.handle_service_error(e, "celebrate_mission") + + @self.router.get("/similar-news", + response_model=SimilarMissionNewsResponse, + status_code=status.HTTP_200_OK, + summary="🔔 유사 사용자 미션 완료 소식 (5가지 기준별 선별)", + description=""" +**5가지 유사도 기준별로 각각 1명씩 총 5개의 미션 완료 소식을 조회합니다.** + +### 🎯 5가지 유사도 기준: +1. **직업 유사도**: 동일 직업 또는 유사 직업군 (테크/케어/서비스) +2. **나이 유사도**: 연령대가 비슷한 사용자 (10세 차이 내) +3. **BMI/체형 유사도**: 체형이 비슷한 사용자 (마른/표준/통통) +4. **혈압 유사도**: 혈압 수치가 비슷한 사용자 (정상/주의/높음) +5. **혈당 유사도**: 혈당 수치가 비슷한 사용자 (정상/주의/높음) + +### 📋 다양한 메시지 형태 (각 기준별 1개씩): +1. **직업 기준**: "IT직군 김OO님이 물마시기 미션을 완료했어요! 💧" +2. **나이 기준**: "23세 홍OO님이 산책을 완료했어요! 🚶‍♀️" +3. **체형 기준**: "통통한 박OO님이 스트레칭을 완료했어요! 🧘‍♂️" +4. **혈압 기준**: "혈압높은 이OO님이 명상을 완료했어요! 🧘‍♀️" +5. **혈당 기준**: "혈당주의 최OO님이 계단오르기를 완료했어요! 🏃‍♂️" + +### ⚡ 성능 최적화: +- **빠른 이모지 매핑**: AI 대신 키워드 기반 매핑으로 빠른 응답 +- **중복 제거**: 같은 사용자가 여러 기준에서 선정되는 것 방지 +- **다양성 보장**: 5가지 다른 기준으로 다양한 유사성 표현 +- **최소 유사도 임계값**: 0.3 이상의 유사도를 가진 사용자만 선별 + +### 🔄 선별 프로세스: +1. **후보 수집**: 벡터 유사도 기준 상위 30명 조회 +2. **기준별 계산**: 각 후보에 대해 5가지 기준별 유사도 점수 계산 +3. **최적 선별**: 각 기준별로 가장 높은 점수의 사용자 1명씩 선택 +4. **중복 제거**: 동일 사용자가 여러 기준에서 선정될 경우 가장 높은 점수 기준만 적용 +5. **결과 반환**: 최대 5개의 다양한 유사 사용자 소식 반환 + +### 💡 유사 직업군 분류: +- **테크 그룹**: 사무직(OFF001), IT직군(ENG001) +- **케어 그룹**: 의료진(MED001), 교육직(EDU001) +- **서비스 그룹**: 서비스직(SRV001) + +### 🏥 건강 특성 분류: +- **BMI**: 마른(<18.5), 표준(18.5-25), 통통(≥25) +- **혈압**: 정상(<130), 주의(130-139), 높음(≥140) +- **혈당**: 정상(<100), 주의(100-125), 높음(≥126) + +### 🔒 개인정보 보호: +- 이름 마스킹 처리 (김OO 형식) +- 구체적인 수치 노출 없이 특성만 표시 +- 다양한 기준으로 분산하여 개인 식별 위험 최소화 + +**이제 5가지 다른 기준으로 선별된 다양한 유사 사용자들의 미션 완료 소식을 빠르게 확인할 수 있습니다!** + """) + async def get_similar_mission_news( + user_id: int = Query(..., gt=0, description="사용자 ID")) -> SimilarMissionNewsResponse: + """유사 사용자 미션 완료 소식 조회 (다층 특성 기반 AI 이모지)""" + try: + self.log_request("get_similar_mission_news", user_id=user_id) + response = await self.mission_service.get_similar_mission_news(user_id) + return response + except Exception as e: + self.handle_service_error(e, "get_similar_mission_news") + + @self.router.post("/upsert-vector", + status_code=status.HTTP_200_OK, + summary="📊 모든 사용자 벡터 일괄 저장/업데이트", + description=""" +**모든 사용자의 건강 중심 벡터를 Pinecone DB에 일괄 저장/업데이트합니다.** + +### 🔄 처리 과정: +1. **전체 사용자 조회**: PostgreSQL에서 모든 사용자 목록 조회 +2. **기존 벡터 확인**: Pinecone에서 이미 저장된 벡터 ID 목록 조회 +3. **신규 사용자 필터링**: 벡터가 없는 사용자만 필터링 +4. **건강 데이터 조회**: 각 사용자별 최신 건강검진 데이터 조회 +5. **건강 중심 벡터 생성**: 1024차원 건강 특성 벡터 생성 +6. **벡터 저장**: Pinecone에 저장 +7. **진행상황 로깅**: 실시간 처리 현황 로그 출력 + +### 🎯 건강 중심 벡터 구성 (1024차원): +- **나이 특성** (50차원): 연령대별 유사도 강화 +- **건강 위험도** (300차원): BMI, 혈압, 혈당, 콜레스테롤 등 주요 지표 +- **상세 건강 특성** (500차원): 세부 건강 데이터 정교한 벡터화 +- **직업 특성** (100차원): 직업별 건강 위험 프로필 +- **생활습관** (74차원): 흡연, 음주 상태 + +### ⚡ 성능 최적화: +- **스킵 로직**: 이미 벡터가 있는 사용자는 건너뛰기 +- **배치 처리**: 여러 사용자를 한 번에 처리 +- **에러 핸들링**: 개별 사용자 실패 시에도 전체 프로세스 계속 진행 +- **진행률 표시**: 전체 진행 상황 실시간 확인 + +### 📊 응답 정보: +- **총 사용자 수**: 전체 사용자 수 +- **기존 벡터 수**: 이미 저장된 벡터 수 +- **신규 처리 수**: 새로 저장된 벡터 수 +- **성공/실패 수**: 처리 결과 통계 +- **소요 시간**: 전체 처리 시간 + +### 🔧 사용 시점: +- 초기 시스템 구축 시 모든 사용자 벡터 생성 +- 정기적인 벡터 데이터 동기화 +- 새로운 사용자 대량 등록 후 벡터 일괄 생성 +- 벡터 알고리즘 업데이트 후 재생성 + +### ⚠️ 주의사항: +- 대량 데이터 처리로 시간이 오래 걸릴 수 있음 +- Pinecone API 요청 한도 고려 필요 +- 처리 중 중단되어도 부분적으로 완료된 데이터는 유지됨 + """) + async def upsert_all_user_vectors(): + """모든 사용자 건강 중심 벡터 일괄 저장/업데이트 (기존 벡터 스킵)""" + try: + self.log_request("upsert_all_user_vectors") + + # 모든 사용자 벡터 일괄 처리 서비스 호출 + result = await self.mission_service.upsert_all_user_vectors() + + if result["success"]: + self.logger.info(f"✅ 모든 사용자 건강 중심 벡터 일괄 처리 완료 - " + f"총 사용자: {result['total_users']}, " + f"기존 벡터: {result['existing_vectors']}, " + f"신규 저장: {result['new_vectors']}, " + f"실패: {result['failed']}") + + return self.create_success_response( + data=result, + message=f"건강 중심 벡터 일괄 처리 완료! 신규 {result['new_vectors']}개 저장, " + f"기존 {result['existing_vectors']}개 스킵" + ) + else: + self.logger.warning(f"⚠️ 벡터 일괄 처리 부분 실패 - " + f"성공: {result['new_vectors']}, 실패: {result['failed']}") + + return self.create_success_response( + data=result, + message=f"벡터 일괄 처리 부분 완료. 일부 사용자 처리 실패: {result['failed']}개" + ) + + except DatabaseException as e: + self.logger.error(f"❌ 데이터베이스 오류 - error: {str(e)}") + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="데이터베이스 연결에 문제가 있습니다. 잠시 후 다시 시도해 주세요." + ) + except Exception as e: + self.logger.error(f"❌ 벡터 일괄 처리 중 예상치 못한 오류: {str(e)}", exc_info=True) + + # 일괄 처리 실패해도 서비스는 계속 동작 가능하도록 처리 + return self.create_success_response( + data={ + "success": False, + "total_users": 0, + "existing_vectors": 0, + "new_vectors": 0, + "failed": 0, + "error_message": "벡터 일괄 처리에 실패했지만 개별 기능은 정상 이용 가능합니다.", + "error_type": type(e).__name__ + }, + message="벡터 일괄 처리에 실패했지만 서비스는 계속 이용 가능합니다." + ) + + +# 컨트롤러 인스턴스 생성 +mission_controller = MissionController() diff --git a/app/controllers/status_controller.py b/app/controllers/status_controller.py new file mode 100644 index 0000000..cde8cd4 --- /dev/null +++ b/app/controllers/status_controller.py @@ -0,0 +1,98 @@ +# app/controllers/status_controller.py +""" +HealthSync AI 헬스체크 컨트롤러 (DB 연결 기능 추가) +""" +from fastapi import APIRouter, Depends, status, Query, HTTPException +from app.controllers.base_controller import BaseController +from app.models.base import BaseResponse +from app.core.dependencies import get_settings +from app.config.settings import Settings +from app.utils.database_utils import simple_db +import time, psutil +from datetime import datetime +from typing import Dict, Any, List + +class StatusController(BaseController): + """상태체크 관련 컨트롤러 (DB 기능 포함)""" + + def __init__(self): + super().__init__() + self.router = APIRouter() + self._setup_routes() + + def _setup_routes(self): + """라우트 설정""" + + @self.router.get("/check", response_model=BaseResponse, + status_code=status.HTTP_200_OK, + summary="🔧 시스템 상태 확인") + async def get_system_status(app_settings: Settings = Depends(get_settings)): + """시스템 상태 확인""" + try: + self.log_request("system_status") + try: + memory_mb = round(psutil.Process().memory_info().rss / 1024 / 1024, 2) + except: + memory_mb = 0.0 + + status_data = { + "status": "running", + "service": app_settings.app_name, + "version": app_settings.app_version, + "memory_mb": memory_mb, + "environment": "development" if app_settings.debug else "production" + } + + return self.create_success_response( + data=status_data, + message="서비스가 정상 동작 중입니다! 🚀" + ) + + except Exception as e: + self.handle_service_error(e, "system_status") + + @self.router.get("/database", + response_model=BaseResponse[Dict[str, Any]], + status_code=status.HTTP_200_OK, + summary="🗄️ 데이터베이스 연결 테스트") + async def test_database_connection(): + """PostgreSQL 데이터베이스 연결 상태 확인""" + try: + self.log_request("database_connection_test") + + connection_info = await simple_db.test_connection() + + if connection_info.get("status") == "connected": + return self.create_success_response( + data=connection_info, + message="데이터베이스 연결이 정상입니다! 🎉" + ) + else: + return self.create_success_response( + data=connection_info, + message="데이터베이스 연결에 문제가 있습니다. ⚠️" + ) + + except Exception as e: + self.handle_service_error(e, "database_connection_test") + + @self.router.get("/tables", + response_model=BaseResponse[List[Dict[str, Any]]], + status_code=status.HTTP_200_OK, + summary="📋 테이블 목록 조회") + async def list_database_tables(): + """데이터베이스 테이블 목록 조회""" + try: + self.log_request("database_tables_list") + + tables = await simple_db.list_tables() + + return self.create_success_response( + data=tables, + message=f"총 {len(tables)}개의 테이블을 조회했습니다. 📊" + ) + + except Exception as e: + self.handle_service_error(e, "database_tables_list") + +status_controller = StatusController() diff --git a/app/core/__init__.py b/app/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/core/dependencies.py b/app/core/dependencies.py new file mode 100644 index 0000000..d1be4e3 --- /dev/null +++ b/app/core/dependencies.py @@ -0,0 +1,16 @@ +""" +HealthSync AI 공통 의존성 관리 +""" +from fastapi import Depends, HTTPException, status, Query +from app.config.settings import settings, Settings +import logging + +logger = logging.getLogger(__name__) + +def get_settings() -> Settings: + """설정 의존성 주입""" + return settings + +def get_current_user_id(user_id: int = Query(..., gt=0, description="사용자 ID")) -> int: + """현재 사용자 ID 추출""" + return user_id \ No newline at end of file diff --git a/app/dto/__init__.py b/app/dto/__init__.py new file mode 100644 index 0000000..502d382 --- /dev/null +++ b/app/dto/__init__.py @@ -0,0 +1,4 @@ +""" +HealthSync AI DTO (Data Transfer Object) 패키지 +API 요청/응답 모델들을 관리합니다. +""" \ No newline at end of file diff --git a/app/dto/request/__init__.py b/app/dto/request/__init__.py new file mode 100644 index 0000000..951b760 --- /dev/null +++ b/app/dto/request/__init__.py @@ -0,0 +1,10 @@ +# app/dto/request/__init__.py +""" +HealthSync AI 요청 DTO 패키지 +API 요청 모델들을 관리합니다. +""" +from .mission_request import MissionRecommendRequest +from .chat_request import ChatRequest +from .celebration_request import CelebrationRequest + +__all__ = ["MissionRecommendRequest", "ChatRequest", "CelebrationRequest"] \ No newline at end of file diff --git a/app/dto/request/celebration_request.py b/app/dto/request/celebration_request.py new file mode 100644 index 0000000..4f3332d --- /dev/null +++ b/app/dto/request/celebration_request.py @@ -0,0 +1,19 @@ +# app/dto/request/celebration_request.py +""" +HealthSync AI 미션 축하 요청 DTO +""" +from pydantic import BaseModel, Field + + +class CelebrationRequest(BaseModel): + """미션 축하 요청 DTO""" + userId: int = Field(..., description="사용자 ID") + missionId: int = Field(..., description="달성한 미션 ID") + + class Config: + json_schema_extra = { + "example": { + "userId": 1, + "missionId": 1101 + } + } \ No newline at end of file diff --git a/app/dto/request/chat_request.py b/app/dto/request/chat_request.py new file mode 100644 index 0000000..6d52642 --- /dev/null +++ b/app/dto/request/chat_request.py @@ -0,0 +1,19 @@ +# app/dto/request/chat_request.py +""" +HealthSync AI 챗봇 상담 요청 DTO +""" +from pydantic import BaseModel, Field + + +class ChatRequest(BaseModel): + """챗봇 상담 요청 DTO""" + message: str = Field(..., min_length=1, max_length=500, description="질문 내용") + user_id: int = Field(..., gt=0, description="사용자 ID") + + class Config: + json_schema_extra = { + "example": { + "message": "혈압이 높은데 어떻게 관리해야 하나요?", + "user_id": 1 + } + } \ No newline at end of file diff --git a/app/dto/request/mission_request.py b/app/dto/request/mission_request.py new file mode 100644 index 0000000..4f97733 --- /dev/null +++ b/app/dto/request/mission_request.py @@ -0,0 +1,16 @@ +""" +HealthSync AI 미션 추천 요청 DTO +""" +from pydantic import BaseModel, Field + + +class MissionRecommendRequest(BaseModel): + """미션 추천 요청 DTO""" + user_id: int = Field(..., description="사용자 ID") + + class Config: + json_schema_extra = { + "example": { + "user_id": 1 + } + } \ No newline at end of file diff --git a/app/dto/response/__init__.py b/app/dto/response/__init__.py new file mode 100644 index 0000000..3f18f18 --- /dev/null +++ b/app/dto/response/__init__.py @@ -0,0 +1,23 @@ +# app/dto/response/__init__.py +""" +HealthSync AI 응답 DTO 패키지 +API 응답 모델들을 관리합니다. +""" +from .mission_response import MissionRecommendationResponse, RecommendedMission +from .health_response import HealthDiagnosisResponse +from .chat_response import ChatResponse +from .chat_history_response import ChatHistoryResponse, ChatHistoryItem +from .celebration_response import CelebrationResponse +from .similar_mission_news_response import SimilarMissionNewsResponse, MissionNewsItem + +__all__ = [ + "MissionRecommendationResponse", + "RecommendedMission", + "HealthDiagnosisResponse", + "ChatResponse", + "ChatHistoryResponse", + "ChatHistoryItem", + "CelebrationResponse", + "SimilarMissionNewsResponse", + "MissionNewsItem" +] \ No newline at end of file diff --git a/app/dto/response/celebration_response.py b/app/dto/response/celebration_response.py new file mode 100644 index 0000000..9b1eb38 --- /dev/null +++ b/app/dto/response/celebration_response.py @@ -0,0 +1,17 @@ +# app/dto/response/celebration_response.py +""" +HealthSync AI 미션 축하 응답 DTO +""" +from pydantic import BaseModel, Field + + +class CelebrationResponse(BaseModel): + """미션 축하 응답 DTO""" + congratsMessage: str = Field(..., description="축하 메시지") + + class Config: + json_schema_extra = { + "example": { + "congratsMessage": "🎉 30분 걷기 미션 완료! 건강한 하루를 만들어가시는 모습이 정말 멋져요! 💪✨" + } + } \ No newline at end of file diff --git a/app/dto/response/chat_history_response.py b/app/dto/response/chat_history_response.py new file mode 100644 index 0000000..424a13f --- /dev/null +++ b/app/dto/response/chat_history_response.py @@ -0,0 +1,50 @@ +# app/dto/response/chat_history_response.py +""" +HealthSync AI 채팅 히스토리 응답 DTO +""" +from pydantic import BaseModel, Field +from datetime import datetime +from typing import List, Optional + + +class ChatHistoryItem(BaseModel): + """개별 채팅 기록 항목""" + message_id: int = Field(..., description="메시지 ID") + message_type: str = Field(..., description="메시지 타입") + message_content: Optional[str] = Field(None, description="사용자 질문 내용 (축하/독려 메시지의 경우 null)") + response_content: str = Field(..., description="AI 응답 내용") + created_at: datetime = Field(..., description="생성 시간") + + class Config: + json_encoders = { + datetime: lambda v: v.isoformat() + } + + +class ChatHistoryResponse(BaseModel): + """채팅 히스토리 응답 DTO""" + chat_history: List[ChatHistoryItem] = Field(..., description="채팅 기록 목록") + total_count: int = Field(..., description="전체 채팅 수") + + class Config: + json_schema_extra = { + "example": { + "chat_history": [ + { + "message_id": 15, + "message_type": "consultation", + "message_content": "혈압이 높은데 어떻게 관리해야 하나요?", + "response_content": "혈압 관리를 위해서는 규칙적인 유산소 운동과 저염식 식단을 권장합니다.", + "created_at": "2025-06-16T14:30:00.000Z" + }, + { + "message_id": 12, + "message_type": "consultation", + "message_content": "콜레스테롤 수치가 높다고 나왔는데 음식 조절 방법을 알려주세요.", + "response_content": "콜레스테롤 관리를 위해 포화지방 섭취를 줄이고 오메가3가 풍부한 생선을 섭취하시기 바랍니다.", + "created_at": "2025-06-15T10:15:00.000Z" + } + ], + "total_count": 2 + } + } \ No newline at end of file diff --git a/app/dto/response/chat_response.py b/app/dto/response/chat_response.py new file mode 100644 index 0000000..4d99a49 --- /dev/null +++ b/app/dto/response/chat_response.py @@ -0,0 +1,23 @@ +# app/dto/response/chat_response.py +""" +HealthSync AI 챗봇 상담 응답 DTO +""" +from pydantic import BaseModel, Field +from datetime import datetime + + +class ChatResponse(BaseModel): + """챗봇 상담 응답 DTO""" + response: str = Field(..., description="AI 답변 내용") + timestamp: datetime = Field(..., description="답변 생성 시간") + + class Config: + json_encoders = { + datetime: lambda v: v.isoformat() + } + json_schema_extra = { + "example": { + "response": "혈압 관리를 위해서는 규칙적인 유산소 운동과 저염식 식단을 권장합니다. 특히 IT 업무 특성상 스트레스 관리도 중요하므로 명상이나 심호흡 운동을 병행하시기 바랍니다.", + "timestamp": "2025-06-16T10:30:00.000Z" + } + } \ No newline at end of file diff --git a/app/dto/response/health_response.py b/app/dto/response/health_response.py new file mode 100644 index 0000000..15eb564 --- /dev/null +++ b/app/dto/response/health_response.py @@ -0,0 +1,17 @@ +# app/dto/response/health_response.py +""" +HealthSync AI 건강 분석 응답 DTO +""" +from pydantic import BaseModel, Field + + +class HealthDiagnosisResponse(BaseModel): + """건강 진단 응답 DTO""" + threeSentenceSummary: str = Field(..., description="3줄 요약 진단") + + class Config: + json_schema_extra = { + "example": { + "threeSentenceSummary": "혈압과 콜레스테롤 수치가 정상 범위를 약간 초과하여 심혈관 질환 위험이 있습니다. IT 개발자 직업 특성상 장시간 앉아있어 운동 부족과 스트레스가 주요 원인으로 보입니다. 규칙적인 유산소 운동과 식단 조절을 통해 충분히 개선 가능한 상태입니다." + } + } \ No newline at end of file diff --git a/app/dto/response/mission_response.py b/app/dto/response/mission_response.py new file mode 100644 index 0000000..8267134 --- /dev/null +++ b/app/dto/response/mission_response.py @@ -0,0 +1,46 @@ +""" +HealthSync AI 미션 추천 응답 DTO +""" +from pydantic import BaseModel, Field +from typing import List + + +class RecommendedMission(BaseModel): + """추천 미션 개별 DTO""" + title: str = Field(..., description="미션 제목") + daily_target_count: int = Field(..., ge=1, le=5, description="일일 목표 횟수 (1-5)") + reason: str = Field(..., description="추천 이유") + + class Config: + json_schema_extra = { + "example": { + "title": "하루 30분 이상 걷기", + "daily_target_count": 1, + "reason": "유산소 운동을 통해 심혈관 건강을 개선하고 체중 관리에 도움이 됩니다." + } + } + + +class MissionRecommendationResponse(BaseModel): + """미션 추천 응답 DTO""" + missions: List[RecommendedMission] = Field(..., description="추천 미션 목록") + + class Config: + json_schema_extra = { + "example": { + "missions": [ + { + "mission_id": "mission_001", + "title": "하루 30분 이상 걷기", + "daily_target_count": 1, + "reason": "혈압이 높고 체중이 증가한 상태로, 유산소 운동을 통해 심혈관 건강을 개선하고 체중 관리에 도움이 됩니다." + }, + { + "mission_id": "mission_002", + "title": "물 2컵씩 마시기", + "daily_target_count": 4, + "reason": "충분한 수분 섭취로 혈액 순환을 개선하고 신진대사를 활성화하여 전반적인 건강 상태를 향상시킵니다." + } + ] + } + } \ No newline at end of file diff --git a/app/dto/response/similar_mission_news_response.py b/app/dto/response/similar_mission_news_response.py new file mode 100644 index 0000000..b9fc0df --- /dev/null +++ b/app/dto/response/similar_mission_news_response.py @@ -0,0 +1,53 @@ +# app/dto/response/similar_mission_news_response.py +""" +HealthSync AI 유사 사용자 미션 완료 소식 응답 DTO +""" +from pydantic import BaseModel, Field +from datetime import datetime +from typing import List + + +class MissionNewsItem(BaseModel): + """개별 미션 소식 항목""" + message: str = Field(..., description="미션 완료 소식 메시지") + mission_category: str = Field(..., description="미션 카테고리") + similarity_score: float = Field(..., description="유사도 점수 (0-1)") + completed_at: datetime = Field(..., description="완료 시간") + + class Config: + json_encoders = { + datetime: lambda v: v.isoformat() + } + + +class SimilarMissionNewsResponse(BaseModel): + """유사 사용자 미션 완료 소식 응답 DTO""" + similar_mission_news: List[MissionNewsItem] = Field(..., description="유사 사용자 미션 소식 목록") + total_count: int = Field(..., description="전체 소식 수") + + class Config: + json_schema_extra = { + "example": { + "similar_mission_news": [ + { + "message": "IT직군 김OO님이 물마시기 미션을 완료했어요! 💧", + "mission_category": "hydration", + "similarity_score": 0.87, + "completed_at": "2025-06-17T10:30:00.000Z" + }, + { + "message": "23세 혈당높은 홍OO님이 산책 3회를 완료했어요! 🚶‍♀️", + "mission_category": "exercise", + "similarity_score": 0.82, + "completed_at": "2025-06-17T09:15:00.000Z" + }, + { + "message": "사무직 박OO님이 스트레칭 미션을 달성했어요! 🧘‍♂️", + "mission_category": "stretching", + "similarity_score": 0.79, + "completed_at": "2025-06-17T08:45:00.000Z" + } + ], + "total_count": 3 + } + } \ No newline at end of file diff --git a/app/exceptions/__init__.py b/app/exceptions/__init__.py new file mode 100644 index 0000000..44fca97 --- /dev/null +++ b/app/exceptions/__init__.py @@ -0,0 +1,19 @@ +# app/exceptions/__init__.py +""" +HealthSync AI 예외 패키지 +""" +from .custom_exceptions import ( + HealthSyncException, + UserNotFoundException, + HealthDataNotFoundException, + DatabaseException, + ClaudeAPIException +) + +__all__ = [ + "HealthSyncException", + "UserNotFoundException", + "HealthDataNotFoundException", + "DatabaseException", + "ClaudeAPIException" +] \ No newline at end of file diff --git a/app/exceptions/custom_exceptions.py b/app/exceptions/custom_exceptions.py new file mode 100644 index 0000000..a880630 --- /dev/null +++ b/app/exceptions/custom_exceptions.py @@ -0,0 +1,42 @@ +# app/exceptions/custom_exceptions.py +""" +HealthSync AI 사용자 정의 예외 클래스 +""" + + +class HealthSyncException(Exception): + """HealthSync AI 기본 예외 클래스""" + def __init__(self, message: str, error_code: str = None): + self.message = message + self.error_code = error_code + super().__init__(self.message) + + +class UserNotFoundException(HealthSyncException): + """사용자를 찾을 수 없는 경우""" + def __init__(self, user_id: int): + super().__init__(f"사용자 ID {user_id}를 찾을 수 없습니다.", "USER_NOT_FOUND") + + +class HealthDataNotFoundException(HealthSyncException): + """건강검진 데이터를 찾을 수 없는 경우""" + def __init__(self, user_id: int): + super().__init__(f"사용자 ID {user_id}님의 건강검진 데이터를 찾을 수 없습니다. 건강검진 데이터를 먼저 등록해 주세요.", "HEALTH_DATA_NOT_FOUND") + + +class DatabaseException(HealthSyncException): + """데이터베이스 관련 예외""" + def __init__(self, message: str): + super().__init__(f"데이터베이스 오류: {message}", "DATABASE_ERROR") + + +class ClaudeAPIException(HealthSyncException): + """Claude API 관련 예외""" + def __init__(self, message: str): + super().__init__(f"Claude AI 서비스 오류: {message}", "CLAUDE_API_ERROR") + +class MissionNotFoundException(HealthSyncException): + """미션을 찾을 수 없는 경우""" + + def __init__(self, mission_id: int): + super().__init__(f"미션 ID {mission_id}를 찾을 수 없습니다.", "MISSION_NOT_FOUND") \ No newline at end of file diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..e40c7f7 --- /dev/null +++ b/app/main.py @@ -0,0 +1,223 @@ +# app/main.py +""" +HealthSync AI - FastAPI 메인 애플리케이션 (MVC 패턴) - 벡터DB 및 Redis 연동 추가 +""" +from fastapi import FastAPI, Request +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import RedirectResponse, JSONResponse +from fastapi.exceptions import RequestValidationError +from fastapi.openapi.utils import get_openapi +from app.views.status_views import status_router +from app.views.mission_views import mission_router +from app.views.health_views import health_router +from app.views.chat_views import chat_router +from app.config.settings import settings +from app.models.base import ErrorResponse +from app.utils.vector_client import pinecone_client +from app.utils.redis_client import redis_client +import time +import logging +from datetime import datetime + +# 로깅 설정 +logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s") +logger = logging.getLogger("HealthSync_AI") + +# FastAPI 앱 생성 +app = FastAPI( + title=settings.app_name, + description=f""" +{settings.app_description} + +### 🚀 주요 기능 + +- **🎯 AI 미션 추천**: 개인 건강 데이터 기반 맞춤형 건강 미션 추천 +- **🔬 AI 건강 진단**: 건강검진 데이터 3줄 요약 진단 +- **💬 AI 건강 상담**: 개인 건강 데이터 기반 맞춤형 건강 상담 챗봇 +- **🎉 미션 축하**: AI 기반 개인화된 미션 달성 축하 메시지 +- **🔔 유사 미션 소식**: 벡터DB 기반 유사 사용자 미션 완료 소식 +- **🏥 건강 상태 분석**: 건강검진 데이터 종합 분석 +- **💡 개인화 서비스**: 직업군별 특화된 건강 관리 솔루션 + +### 🔧 기술 스택 + +- **Framework**: FastAPI {settings.app_version} +- **Pattern**: MVC (Model-View-Controller) +- **Language**: Python 3.11+ +- **Database**: PostgreSQL +- **VectorDB**: Pinecone (유사도 검색) +- **Cache**: Redis (스마트 캐싱) +- **AI**: Claude API +- **Docs**: OpenAPI 3.0 + +--- + +💡 **Tip**: 왼쪽 사이드바에서 API 엔드포인트를 탐색해보세요! + """, + version=settings.app_version, + docs_url="/api/intelligence/docs", + redoc_url="/api/intelligence/redoc", + openapi_url="/api/intelligence/openapi.json" +) + + +# OpenAPI 스키마 커스터마이징 +def custom_openapi(): + if app.openapi_schema: + return app.openapi_schema + + openapi_schema = get_openapi( + title=settings.app_name, + version=settings.app_version, + description=settings.app_description, + routes=app.routes, + ) + + # OpenAPI 3.1.0 명시적 설정 + openapi_schema["openapi"] = "3.1.0" + + # 서버 정보 추가 + openapi_schema["servers"] = [ + { + "url": f"http://{settings.host}:{settings.port}", + "description": "Development server" + } + ] + + # 태그 정보 추가 + openapi_schema["tags"] = [ + { + "name": "🏥 Status Check", + "description": "시스템 상태 확인 및 데이터베이스 연결 테스트" + }, + { + "name": "🎯 Mission Management", + "description": "AI 기반 건강 미션 추천 및 축하 메시지" + }, + { + "name": "🔬 Health Analysis", + "description": "건강검진 데이터 AI 분석 및 3줄 요약 진단" + }, + { + "name": "💬 Chat Consultation", + "description": "개인 건강 데이터 기반 AI 건강 상담 챗봇" + } + ] + + app.openapi_schema = openapi_schema + return app.openapi_schema + + +app.openapi = custom_openapi + +# CORS 미들웨어 설정 +app.add_middleware( + CORSMiddleware, + allow_origins=settings.cors_origins, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + + +# 요청 처리 시간 미들웨어 +@app.middleware("http") +async def add_process_time_header(request: Request, call_next): + start_time = time.time() + logger.info(f"📥 Request: {request.method} {request.url.path}") + + response = await call_next(request) + + process_time = time.time() - start_time + response.headers["X-Process-Time"] = str(round(process_time, 4)) + response.headers["X-Service"] = settings.app_name + + logger.info( + f"📤 Response: {request.method} {request.url.path} - Status: {response.status_code} - Time: {process_time:.4f}s") + return response + + +# API 라우터 등록 +app.include_router(status_router, prefix=settings.api_v1_prefix) +app.include_router(mission_router, prefix=settings.api_v1_prefix) +app.include_router(health_router, prefix=settings.api_v1_prefix) +app.include_router(chat_router, prefix=settings.api_v1_prefix) + + +@app.get("/", include_in_schema=False) +async def root(): + """루트 경로 - API 문서로 리다이렉트""" + return RedirectResponse(url="/api/intelligence/docs") + + +@app.get("/docs", include_in_schema=False) +async def docs_redirect(): + """docs 경로 - API 문서로 리다이렉트""" + return RedirectResponse(url="/api/intelligence/docs") + + +# 예외 처리 +@app.exception_handler(RequestValidationError) +async def validation_exception_handler(request: Request, exc: RequestValidationError): + error_response = ErrorResponse( + error_code="VALIDATION_ERROR", + message="입력 데이터 검증에 실패했습니다.", + details={"errors": exc.errors()}, + timestamp=datetime.now() + ) + + return JSONResponse( + status_code=422, + content=error_response.model_dump(mode='json') + ) + + +@app.on_event("startup") +async def startup_event(): + logger.info(f"🚀 {settings.app_name} v{settings.app_version} 서비스 시작") + + # 벡터DB 및 Redis 연결 초기화 + try: + logger.info("🔗 외부 서비스 연결 초기화 중...") + + # Pinecone 벡터DB 초기화 + if settings.pinecone_api_key and settings.pinecone_api_key != "your_pinecone_api_key_here": + await pinecone_client.initialize() + logger.info("✅ Pinecone 벡터DB 연결 완료") + else: + logger.warning("⚠️ Pinecone API 키가 설정되지 않음 - 유사 미션 소식 기능 비활성화") + + # Redis 캐시 연결 + if settings.redis_host: + await redis_client.connect() + logger.info("✅ Redis 캐시 연결 완료") + else: + logger.warning("⚠️ Redis 설정이 없음 - 캐싱 기능 비활성화") + + except Exception as e: + logger.error(f"❌ 외부 서비스 연결 실패: {str(e)}") + logger.warning("⚠️ 일부 기능이 제한될 수 있습니다.") + + # API 엔드포인트 정보 출력 + logger.info(f"📝 API 문서: http://{settings.host}:{settings.port}/api/missions/docs") + logger.info(f"🎯 미션 추천 API: http://{settings.host}:{settings.port}/api/missions/recommend") + logger.info(f"🎉 미션 축하 API: http://{settings.host}:{settings.port}/api/missions/celebrate") + logger.info(f"🔔 유사 미션 소식 API: http://{settings.host}:{settings.port}/api/missions/similar-news") + logger.info(f"📊 사용자 벡터 저장 API: http://{settings.host}:{settings.port}/api/missions/upsert-vector") + logger.info(f"🔬 건강 진단 API: http://{settings.host}:{settings.port}/api/health/diagnosis") + logger.info(f"💬 건강 상담 API: http://{settings.host}:{settings.port}/api/chat/consultation") + + +@app.on_event("shutdown") +async def shutdown_event(): + logger.info("🛑 HealthSync AI 서비스 종료 중...") + + # 외부 서비스 연결 해제 + try: + if redis_client._connected: + await redis_client.disconnect() + logger.info("✅ Redis 연결 해제 완료") + except Exception as e: + logger.error(f"❌ Redis 연결 해제 실패: {str(e)}") + + logger.info("🛑 HealthSync AI 서비스 종료 완료") \ No newline at end of file diff --git a/app/models/__init__.py b/app/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/models/base.py b/app/models/base.py new file mode 100644 index 0000000..a8baf32 --- /dev/null +++ b/app/models/base.py @@ -0,0 +1,36 @@ +""" +HealthSync AI 기본 데이터 모델 +""" +from pydantic import BaseModel, Field +from datetime import datetime +from typing import Optional, Generic, TypeVar, List +from enum import Enum + +# Generic 타입 정의 +T = TypeVar('T') + +class BaseResponse(BaseModel, Generic[T]): + """기본 응답 모델""" + success: bool = Field(default=True, description="요청 성공 여부") + message: str = Field(default="", description="응답 메시지") + data: Optional[T] = Field(default=None, description="응답 데이터") + timestamp: datetime = Field(default_factory=datetime.now, description="응답 시간") + + class Config: + json_encoders = { + datetime: lambda v: v.isoformat() + } + + +class ErrorResponse(BaseModel): + """에러 응답 모델""" + success: bool = Field(default=False, description="요청 성공 여부") + error_code: str = Field(..., description="에러 코드") + message: str = Field(..., description="에러 메시지") + details: Optional[dict] = Field(default=None, description="에러 상세 정보") + timestamp: datetime = Field(default_factory=datetime.now, description="에러 발생 시간") + + class Config: + json_encoders = { + datetime: lambda v: v.isoformat() + } \ No newline at end of file diff --git a/app/models/chat_message.py b/app/models/chat_message.py new file mode 100644 index 0000000..602c06d --- /dev/null +++ b/app/models/chat_message.py @@ -0,0 +1,28 @@ +# app/models/chat_message.py +""" +HealthSync AI 채팅 메시지 모델 +""" +from pydantic import BaseModel, Field +from datetime import datetime +from typing import Optional +from enum import Enum + +class MessageType(str, Enum): + """메시지 타입""" + CONSULTATION = "consultation" # 상담 + CELEBRATION = "celebration" # 축하 + ENCOURAGEMENT = "encouragement" # 독려 + +class ChatMessage(BaseModel): + """채팅 메시지 모델""" + message_id: Optional[int] = Field(None, description="메시지 ID") + member_serial_number: int = Field(..., description="회원 일련번호") + message_type: str = Field(..., max_length=20, description="메시지 타입") + message_content: Optional[str] = Field(None, description="메시지 내용") + response_content: Optional[str] = Field(None, description="AI 응답 내용") + created_at: datetime = Field(default_factory=datetime.now, description="생성일시") + + class Config: + json_encoders = { + datetime: lambda v: v.isoformat() + } \ No newline at end of file diff --git a/app/models/common.py b/app/models/common.py new file mode 100644 index 0000000..ec4c5bc --- /dev/null +++ b/app/models/common.py @@ -0,0 +1,56 @@ +""" +HealthSync AI 공통 모델 (Common Service) +""" +from pydantic import BaseModel, Field +from datetime import datetime +from typing import Optional, Dict, Any, List +from enum import Enum + +class EventType(str, Enum): + """이벤트 타입""" + USER_REGISTERED = "user_registered" + HEALTH_DATA_SYNCED = "health_data_synced" + GOAL_SETUP = "goal_setup" + MISSION_COMPLETED = "mission_completed" + AI_ANALYSIS_COMPLETED = "ai_analysis_completed" + NOTIFICATION_SENT = "notification_sent" + +class EventStore(BaseModel): + """이벤트 저장소""" + event_id: int = Field(..., description="이벤트 ID") + aggregate_id: str = Field(..., max_length=36, description="집계 ID") + event_type: str = Field(..., max_length=100, description="이벤트 타입") + event_data: Optional[str] = Field(None, description="이벤트 데이터") + member_serial_number: Optional[int] = Field(None, description="회원 일련번호") + service_name: Optional[str] = Field(None, max_length=50, description="서비스명") + created_at: datetime = Field(default_factory=datetime.now, description="생성일시") + +class SystemConfig(BaseModel): + """시스템 설정""" + config_id: int = Field(..., description="설정 ID") + config_key: str = Field(..., max_length=200, description="설정 키") + config_value: Optional[str] = Field(None, description="설정 값") + description: Optional[str] = Field(None, max_length=500, description="설명") + created_at: datetime = Field(default_factory=datetime.now, description="생성일시") + +class APIResponse(BaseModel): + """표준 API 응답""" + success: bool = Field(True, description="성공 여부") + message: str = Field(..., description="응답 메시지") + data: Optional[Dict[str, Any]] = Field(None, description="응답 데이터") + timestamp: datetime = Field(default_factory=datetime.now, description="응답 시각") + + class Config: + json_encoders = { + datetime: lambda v: v.isoformat() + } + +class PaginatedResponse(BaseModel): + """페이지네이션 응답""" + items: List[Any] = Field(..., description="아이템 목록") + total: int = Field(..., description="전체 수") + page: int = Field(..., description="현재 페이지") + size: int = Field(..., description="페이지 크기") + pages: int = Field(..., description="전체 페이지 수") + has_next: bool = Field(..., description="다음 페이지 존재 여부") + has_prev: bool = Field(..., description="이전 페이지 존재 여부") \ No newline at end of file diff --git a/app/models/goal.py b/app/models/goal.py new file mode 100644 index 0000000..4e64869 --- /dev/null +++ b/app/models/goal.py @@ -0,0 +1,113 @@ +""" +HealthSync AI 목표 관리 관련 모델 (Goal Service) +""" +from pydantic import BaseModel, Field +from datetime import datetime, date +from typing import Optional, List, Dict, Any +from enum import Enum + +class MissionStatus(str, Enum): + """미션 상태""" + ACTIVE = "active" + COMPLETED = "completed" + PAUSED = "paused" + CANCELLED = "cancelled" + +class GoalType(str, Enum): + """목표 타입""" + DAILY = "daily" + WEEKLY = "weekly" + MONTHLY = "monthly" + +class DifficultyLevel(str, Enum): + """난이도 레벨""" + BEGINNER = "beginner" + INTERMEDIATE = "intermediate" + ADVANCED = "advanced" + +class MissionCategory(str, Enum): + """미션 카테고리""" + EXERCISE = "exercise" + NUTRITION = "nutrition" + MENTAL_HEALTH = "mental_health" + HYDRATION = "hydration" + SLEEP = "sleep" + STRESS_MANAGEMENT = "stress_management" + +class UserMissionGoal(BaseModel): + """사용자 미션 목표""" + mission_id: int = Field(..., description="미션 ID") + member_serial_number: int = Field(..., description="회원 일련번호") + performance_date: date = Field(..., description="수행 날짜") + mission_name: str = Field(..., max_length=100, description="미션명") + mission_description: Optional[str] = Field(None, max_length=200, description="미션 설명") + daily_target_count: int = Field(..., description="일일 목표 횟수") + is_active: bool = Field(True, description="활성 상태") + created_at: datetime = Field(default_factory=datetime.now, description="생성일시") + +class MissionCompletionHistory(BaseModel): + """미션 완료 이력""" + completion_id: int = Field(..., description="완료 ID") + mission_id: int = Field(..., description="미션 ID") + member_serial_number: int = Field(..., description="회원 일련번호") + completion_date: date = Field(..., description="완료 날짜") + daily_target_count: int = Field(..., description="일일 목표 횟수") + daily_completed_count: int = Field(..., description="일일 완료 횟수") + created_at: datetime = Field(default_factory=datetime.now, description="생성일시") + +class MissionSelectionRequest(BaseModel): + """미션 선택 요청""" + user_id: int = Field(..., description="사용자 ID") + selected_mission_ids: List[str] = Field(..., description="선택된 미션 ID 목록") + +class GoalSetupResponse(BaseModel): + """목표 설정 응답""" + goal_id: str = Field(..., description="목표 ID") + selected_missions: List[Dict[str, Any]] = Field(..., description="선택된 미션 목록") + message: str = Field(..., description="응답 메시지") + setup_completed_at: datetime = Field(..., description="설정 완료 일시") + +class ActiveMissionsResponse(BaseModel): + """활성 미션 응답""" + daily_missions: List[Dict[str, Any]] = Field(..., description="일일 미션 목록") + total_missions: int = Field(..., description="전체 미션 수") + today_completed_count: int = Field(..., description="오늘 완료 수") + completion_rate: float = Field(..., description="완료율") + +class MissionCompleteRequest(BaseModel): + """미션 완료 요청""" + user_id: int = Field(..., description="사용자 ID") + completed: bool = Field(..., description="완료 여부") + completed_at: datetime = Field(..., description="완료 일시") + notes: Optional[str] = Field(None, description="메모") + +class MissionCompleteResponse(BaseModel): + """미션 완료 응답""" + message: str = Field(..., description="응답 메시지") + status: str = Field(..., description="상태") + achievement_message: str = Field(..., description="성취 메시지") + new_streak_days: int = Field(..., description="새로운 연속 달성 일수") + total_completed_count: int = Field(..., description="전체 완료 수") + earned_points: int = Field(..., description="획득 포인트") + +class MissionHistoryResponse(BaseModel): + """미션 이력 응답""" + total_achievement_rate: float = Field(..., description="전체 달성률") + period_achievement_rate: float = Field(..., description="기간 달성률") + best_streak: int = Field(..., description="최고 연속 달성") + mission_stats: List[Dict[str, Any]] = Field(..., description="미션별 통계") + chart_data: Optional[Dict[str, Any]] = Field(None, description="차트 데이터") + period: Dict[str, str] = Field(..., description="조회 기간") + insights: List[str] = Field(..., description="인사이트") + +class MissionResetRequest(BaseModel): + """미션 재설정 요청""" + user_id: int = Field(..., description="사용자 ID") + reason: str = Field(..., description="재설정 사유") + current_mission_ids: List[str] = Field(..., description="현재 미션 ID 목록") + +class MissionResetResponse(BaseModel): + """미션 재설정 응답""" + message: str = Field(..., description="응답 메시지") + new_recommendations: List[Dict[str, Any]] = Field(..., description="새로운 추천 미션") + reset_completed_at: datetime = Field(..., description="재설정 완료 일시") diff --git a/app/models/health.py b/app/models/health.py new file mode 100644 index 0000000..0b1c88e --- /dev/null +++ b/app/models/health.py @@ -0,0 +1,134 @@ +""" +HealthSync AI 건강 관련 모델 (Health Service) +""" +from pydantic import BaseModel, Field +from datetime import datetime, date +from typing import Optional, List, Dict, Any +from enum import Enum +from decimal import Decimal + +class RiskLevel(str, Enum): + """위험도 레벨""" + LOW = "low" + MEDIUM = "medium" + HIGH = "high" + CRITICAL = "critical" + +class RangeStatus(str, Enum): + """정상치 범위 상태""" + NORMAL = "normal" + WARNING = "warning" + DANGER = "danger" + +class UploadStatus(str, Enum): + """업로드 상태""" + PENDING = "pending" + SUCCESS = "success" + FAILED = "failed" + +class HealthCheckupRaw(BaseModel): + """건강검진 원천 데이터""" + raw_id: int = Field(..., description="원본 ID") + reference_year: int = Field(..., description="기준 년도") + birth_date: date = Field(..., description="생년월일") + name: str = Field(..., max_length=50, description="이름") + region_code: Optional[int] = Field(None, description="지역 코드") + gender_code: Optional[int] = Field(None, description="성별 코드") + age: Optional[int] = Field(None, description="나이") + height: Optional[int] = Field(None, description="신장(cm)") + weight: Optional[int] = Field(None, description="체중(kg)") + waist_circumference: Optional[int] = Field(None, description="허리둘레(cm)") + visual_acuity_left: Optional[Decimal] = Field(None, description="좌측 시력") + visual_acuity_right: Optional[Decimal] = Field(None, description="우측 시력") + hearing_left: Optional[int] = Field(None, description="좌측 청력") + hearing_right: Optional[int] = Field(None, description="우측 청력") + systolic_bp: Optional[int] = Field(None, description="수축기 혈압") + diastolic_bp: Optional[int] = Field(None, description="이완기 혈압") + fasting_glucose: Optional[int] = Field(None, description="공복혈당") + total_cholesterol: Optional[int] = Field(None, description="총 콜레스테롤") + triglyceride: Optional[int] = Field(None, description="중성지방") + hdl_cholesterol: Optional[int] = Field(None, description="HDL 콜레스테롤") + ldl_cholesterol: Optional[int] = Field(None, description="LDL 콜레스테롤") + hemoglobin: Optional[Decimal] = Field(None, description="혈색소") + urine_protein: Optional[int] = Field(None, description="요단백") + serum_creatinine: Optional[Decimal] = Field(None, description="혈청크레아티닌") + ast: Optional[int] = Field(None, description="AST") + alt: Optional[int] = Field(None, description="ALT") + gamma_gtp: Optional[int] = Field(None, description="감마지티피") + smoking_status: Optional[int] = Field(None, description="흡연 상태") + drinking_status: Optional[int] = Field(None, description="음주 상태") + created_at: datetime = Field(default_factory=datetime.now, description="생성일시") + +class HealthCheckup(BaseModel): + """처리된 건강검진 데이터""" + checkup_id: int = Field(..., description="건강검진 ID") + member_serial_number: int = Field(..., description="회원 일련번호") + raw_id: int = Field(..., description="원본 데이터 ID") + reference_year: int = Field(..., description="기준 년도") + age: Optional[int] = Field(None, description="나이") + height: Optional[int] = Field(None, description="신장(cm)") + weight: Optional[int] = Field(None, description="체중(kg)") + bmi: Optional[Decimal] = Field(None, description="BMI") + waist_circumference: Optional[int] = Field(None, description="허리둘레(cm)") + visual_acuity_left: Optional[Decimal] = Field(None, description="좌측 시력") + visual_acuity_right: Optional[Decimal] = Field(None, description="우측 시력") + hearing_left: Optional[int] = Field(None, description="좌측 청력") + hearing_right: Optional[int] = Field(None, description="우측 청력") + systolic_bp: Optional[int] = Field(None, description="수축기 혈압") + diastolic_bp: Optional[int] = Field(None, description="이완기 혈압") + fasting_glucose: Optional[int] = Field(None, description="공복혈당") + total_cholesterol: Optional[int] = Field(None, description="총 콜레스테롤") + triglyceride: Optional[int] = Field(None, description="중성지방") + hdl_cholesterol: Optional[int] = Field(None, description="HDL 콜레스테롤") + ldl_cholesterol: Optional[int] = Field(None, description="LDL 콜레스테롤") + hemoglobin: Optional[Decimal] = Field(None, description="혈색소") + urine_protein: Optional[int] = Field(None, description="요단백") + serum_creatinine: Optional[Decimal] = Field(None, description="혈청크레아티닌") + ast: Optional[int] = Field(None, description="AST") + alt: Optional[int] = Field(None, description="ALT") + gamma_gtp: Optional[int] = Field(None, description="감마지티피") + smoking_status: Optional[int] = Field(None, description="흡연 상태") + drinking_status: Optional[int] = Field(None, description="음주 상태") + processed_at: Optional[datetime] = Field(None, description="처리일시") + created_at: datetime = Field(default_factory=datetime.now, description="생성일시") + +class HealthNormalRange(BaseModel): + """건강 정상치 기준""" + range_id: int = Field(..., description="범위 ID") + health_item_code: Optional[str] = Field(None, max_length=25, description="건강항목 코드") + health_item_name: Optional[str] = Field(None, max_length=30, description="건강항목명") + gender_code: Optional[int] = Field(None, description="성별 코드 (0:공통, 1:남성, 2:여성)") + unit: Optional[str] = Field(None, max_length=10, description="단위") + normal_range: Optional[str] = Field(None, max_length=15, description="정상 범위") + warning_range: Optional[str] = Field(None, max_length=15, description="주의 범위") + danger_range: Optional[str] = Field(None, max_length=15, description="위험 범위") + note: Optional[str] = Field(None, max_length=50, description="비고") + created_at: datetime = Field(default_factory=datetime.now, description="생성일시") + +class HealthSyncResponse(BaseModel): + """건강검진 연동 응답""" + sync_status: str = Field(..., description="동기화 상태") + message: str = Field(..., description="응답 메시지") + is_ready_for_analysis: bool = Field(..., description="분석 준비 여부") + synced_at: datetime = Field(..., description="동기화 일시") + +class HealthHistoryResponse(BaseModel): + """건강검진 이력 응답""" + user_info: Dict[str, Any] = Field(..., description="사용자 정보") + checkup_records: List[Dict[str, Any]] = Field(..., description="건강검진 기록") + chart_data: Optional[Dict[str, Any]] = Field(None, description="차트 데이터") + normal_range_reference: Optional[Dict[str, Any]] = Field(None, description="정상치 기준") + +class CheckupFileRequest(BaseModel): + """건강검진 파일 업로드 요청""" + user_id: int = Field(..., description="사용자 ID") + file_name: str = Field(..., description="파일명") + file_type: str = Field(..., description="파일 형식") + file_content: str = Field(..., description="파일 내용") + +class FileUploadResponse(BaseModel): + """파일 업로드 응답""" + file_id: str = Field(..., description="파일 ID") + upload_url: str = Field(..., description="업로드 URL") + status: str = Field(..., description="업로드 상태") + message: str = Field(..., description="응답 메시지") \ No newline at end of file diff --git a/app/models/intelligence.py b/app/models/intelligence.py new file mode 100644 index 0000000..3b6a659 --- /dev/null +++ b/app/models/intelligence.py @@ -0,0 +1,142 @@ +""" +HealthSync AI 지능형 서비스 관련 모델 (Intelligence Service) +""" +from pydantic import BaseModel, Field +from datetime import datetime +from typing import Optional, List, Dict, Any +from enum import Enum + +class MessageRole(str, Enum): + """메시지 역할""" + USER = "user" + ASSISTANT = "assistant" + SYSTEM = "system" + +class MessageType(str, Enum): + """메시지 타입""" + QUESTION = "question" + ANSWER = "answer" + NOTIFICATION = "notification" + CELEBRATION = "celebration" + ENCOURAGEMENT = "encouragement" + +class SenderType(str, Enum): + """발신자 타입""" + USER = "user" + AI = "ai" + SYSTEM = "system" + +class NotificationType(str, Enum): + """알림 타입""" + DAILY_ENCOURAGEMENT = "daily_encouragement" + WEEKLY_SUMMARY = "weekly_summary" + MILESTONE_CELEBRATION = "milestone_celebration" + HEALTH_REMINDER = "health_reminder" + MISSION_REMINDER = "mission_reminder" + +class EncouragementLevel(str, Enum): + """격려 레벨""" + LOW = "low" + MEDIUM = "medium" + HIGH = "high" + INTENSIVE = "intensive" + +class ChatMessage(BaseModel): + """채팅 메시지""" + message_id: int = Field(..., description="메시지 ID") + member_serial_number: int = Field(..., description="회원 일련번호") + message_type: str = Field(..., max_length=20, description="메시지 타입") + message_content: Optional[str] = Field(None, description="메시지 내용") + response_content: Optional[str] = Field(None, description="응답 내용") + created_at: datetime = Field(default_factory=datetime.now, description="생성일시") + +class HealthDiagnosisResponse(BaseModel): + """건강 진단 응답""" + three_sentence_summary: List[str] = Field(..., description="3줄 요약") + health_score: int = Field(..., description="건강 점수") + risk_level: str = Field(..., description="위험 레벨") + occupation_considerations: str = Field(..., description="직업별 고려사항") + analysis_timestamp: datetime = Field(..., description="분석 시각") + confidence_score: float = Field(..., description="신뢰도 점수") + +class RecommendedMission(BaseModel): + """추천 미션""" + mission_id: str = Field(..., description="미션 ID") + title: str = Field(..., description="미션 제목") + description: str = Field(..., description="미션 설명") + category: str = Field(..., description="미션 카테고리") + difficulty: str = Field(..., description="난이도") + health_benefit: str = Field(..., description="건강상 이점") + occupation_relevance: str = Field(..., description="직업 연관성") + estimated_time_minutes: int = Field(..., description="예상 소요 시간(분)") + +class MissionRecommendationResponse(BaseModel): + """미션 추천 응답""" + missions: List[RecommendedMission] = Field(..., description="추천 미션 목록") + recommendation_reason: str = Field(..., description="추천 사유") + total_recommended: int = Field(..., description="전체 추천 수") + +class ChatRequest(BaseModel): + """채팅 요청""" + message: str = Field(..., min_length=1, max_length=500, description="메시지 내용") + session_id: str = Field(..., description="세션 ID") + context: Optional[str] = Field(None, description="컨텍스트") + user_id: int = Field(..., description="사용자 ID") + +class ChatResponse(BaseModel): + """채팅 응답""" + response: str = Field(..., description="AI 응답") + session_id: str = Field(..., description="세션 ID") + timestamp: datetime = Field(..., description="응답 시각") + suggested_questions: List[str] = Field(..., description="추천 질문") + response_type: str = Field(..., description="응답 타입") + +class ChatHistoryResponse(BaseModel): + """채팅 이력 응답""" + session_id: str = Field(..., description="세션 ID") + messages: List[Dict[str, Any]] = Field(..., description="메시지 목록") + total_message_count: int = Field(..., description="전체 메시지 수") + cache_expiration: Optional[str] = Field(None, description="캐시 만료 시간") + +class CelebrationRequest(BaseModel): + """축하 메시지 요청""" + user_id: int = Field(..., description="사용자 ID") + mission_id: str = Field(..., description="미션 ID") + achievement_type: str = Field(..., description="성취 타입") + consecutive_days: int = Field(..., description="연속 달성 일수") + total_achievements: int = Field(..., description="전체 성취 수") + +class CelebrationResponse(BaseModel): + """축하 메시지 응답""" + congrats_message: str = Field(..., description="축하 메시지") + achievement_badge: str = Field(..., description="성취 배지") + health_benefit: str = Field(..., description="건강상 이점") + next_milestone: str = Field(..., description="다음 마일스톤") + encouragement_level: str = Field(..., description="격려 레벨") + visual_effect: str = Field(..., description="시각 효과") + +class EncouragementRequest(BaseModel): + """독려 메시지 요청""" + user_id: int = Field(..., description="사용자 ID") + missions_status: List[Dict[str, Any]] = Field(..., description="미션 상태 목록") + +class EncouragementResponse(BaseModel): + """독려 메시지 응답""" + message: str = Field(..., description="독려 메시지") + motivation_type: str = Field(..., description="동기부여 타입") + timing: str = Field(..., description="타이밍") + personalized_tip: str = Field(..., description="개인화된 팁") + priority: str = Field(..., description="우선순위") + +class BatchNotificationRequest(BaseModel): + """배치 알림 요청""" + trigger_time: datetime = Field(..., description="트리거 시간") + target_users: List[str] = Field(..., description="대상 사용자 목록") + notification_type: str = Field(..., description="알림 타입") + +class BatchNotificationResponse(BaseModel): + """배치 알림 응답""" + processed_count: int = Field(..., description="처리된 수") + success_count: int = Field(..., description="성공 수") + failed_count: int = Field(..., description="실패 수") + next_scheduled_time: Optional[datetime] = Field(None, description="다음 예약 시간") diff --git a/app/models/user.py b/app/models/user.py new file mode 100644 index 0000000..323976f --- /dev/null +++ b/app/models/user.py @@ -0,0 +1,59 @@ +""" +HealthSync AI 사용자 관련 모델 (User Service) +""" +from pydantic import BaseModel, Field +from datetime import datetime, date +from typing import Optional +from enum import Enum + +class UserStatus(str, Enum): + """사용자 상태""" + ACTIVE = "active" + INACTIVE = "inactive" + SUSPENDED = "suspended" + +class OccupationType(BaseModel): + """직업 유형 모델""" + occupation_code: str = Field(..., max_length=20, description="직업 코드") + occupation_name: str = Field(..., max_length=100, description="직업명") + category: Optional[str] = Field(None, max_length=50, description="직업 카테고리") + +class User(BaseModel): + """사용자 기본 모델""" + member_serial_number: int = Field(..., description="회원 일련번호") + google_id: str = Field(..., max_length=255, description="구글 ID") + name: str = Field(..., max_length=100, description="사용자 이름") + birth_date: date = Field(..., description="생년월일") + occupation: Optional[str] = Field(None, max_length=50, description="직업") + created_at: datetime = Field(default_factory=datetime.now, description="생성일시") + updated_at: datetime = Field(default_factory=datetime.now, description="수정일시") + last_login_at: Optional[datetime] = Field(None, description="마지막 로그인") + +class UserRegistrationRequest(BaseModel): + """사용자 등록 요청""" + name: str = Field(..., min_length=1, max_length=100, description="사용자 이름") + birth_date: date = Field(..., description="생년월일") + occupation: Optional[str] = Field(None, max_length=50, description="직업") + +class UserRegistrationResponse(BaseModel): + """사용자 등록 응답""" + user_id: int = Field(..., description="사용자 ID") + message: str = Field(..., description="등록 결과 메시지") + status: str = Field(..., description="등록 상태") + +class UserProfileResponse(BaseModel): + """사용자 프로필 응답""" + user_id: int = Field(..., description="사용자 ID") + name: str = Field(..., description="사용자 이름") + age: int = Field(..., description="나이") + occupation: Optional[str] = Field(None, description="직업") + registered_at: datetime = Field(..., description="등록일시") + last_login_at: Optional[datetime] = Field(None, description="마지막 로그인") + +class LoginResponse(BaseModel): + """로그인 응답""" + access_token: str = Field(..., description="액세스 토큰") + refresh_token: str = Field(..., description="리프레시 토큰") + is_new_user: bool = Field(..., description="신규 사용자 여부") + user_id: int = Field(..., description="사용자 ID") + expires_in: int = Field(..., description="토큰 만료 시간(초)") \ No newline at end of file diff --git a/app/repositories/__init__.py b/app/repositories/__init__.py new file mode 100644 index 0000000..9c9c846 --- /dev/null +++ b/app/repositories/__init__.py @@ -0,0 +1,16 @@ +# app/repositories/__init__.py +""" +HealthSync AI 리포지토리 패키지 +데이터베이스 쿼리 로직을 관리합니다. +""" +from .health_repository import HealthRepository +from .chat_repository import ChatRepository +from .mission_repository import MissionRepository +from .similar_mission_repository import SimilarMissionRepository + +__all__ = [ + "HealthRepository", + "ChatRepository", + "MissionRepository", + "SimilarMissionRepository" +] \ No newline at end of file diff --git a/app/repositories/chat_repository.py b/app/repositories/chat_repository.py new file mode 100644 index 0000000..bae9bd9 --- /dev/null +++ b/app/repositories/chat_repository.py @@ -0,0 +1,107 @@ +# app/repositories/chat_repository.py +""" +HealthSync AI 채팅 데이터 리포지토리 +""" +from typing import Dict, Any, Optional, List +from datetime import datetime +from app.repositories.queries.chat_queries import ChatQueries +import logging + +logger = logging.getLogger(__name__) + + +class ChatRepository: + """채팅 데이터 DB 조회/저장 리포지토리""" + + @staticmethod + def _get_db(): + """simple_db를 lazy import로 가져오기 (순환 import 방지)""" + from app.utils.database_utils import simple_db + return simple_db + + @staticmethod + async def save_chat_message(user_id: int, message_type: str, + message_content: Optional[str] = None, + response_content: Optional[str] = None) -> int: + """채팅 메시지 저장 및 ID 반환""" + try: + simple_db = ChatRepository._get_db() + + chat_data = { + "member_serial_number": user_id, + "message_type": message_type, + "message_content": message_content, + "response_content": response_content, + "created_at": datetime.now() + } + + logger.info(f"채팅 메시지 저장 시도 - user_id: {user_id}, type: {message_type}") + + # INSERT ... RETURNING 쿼리 실행 + result = await simple_db.execute_insert_with_return( + ChatQueries.INSERT_CHAT_MESSAGE_WITH_RETURN, + chat_data + ) + + if result and "message_id" in result: + message_id = result["message_id"] + logger.info(f"채팅 메시지 저장 성공 - message_id: {message_id}, user_id: {user_id}") + return message_id + else: + raise Exception("INSERT RETURNING에서 message_id 반환되지 않음") + + except Exception as e: + logger.error(f"채팅 메시지 저장 실패 - user_id: {user_id}, error: {str(e)}") + logger.error(f"저장 시도 데이터: {chat_data}") + raise Exception(f"채팅 메시지 저장 실패: {str(e)}") + + @staticmethod + async def update_chat_message_response(message_id: int, response_content: str) -> bool: + """채팅 메시지 응답 내용 업데이트""" + try: + simple_db = ChatRepository._get_db() + + update_data = { + "message_id": message_id, + "response_content": response_content, + "updated_at": datetime.now() + } + + logger.info(f"채팅 메시지 응답 업데이트 시도 - message_id: {message_id}") + + # UPDATE 쿼리 실행 + affected_rows = await simple_db.execute_insert_update( + ChatQueries.UPDATE_CHAT_MESSAGE_RESPONSE, + update_data + ) + + # None 체크 추가 + if affected_rows is not None and affected_rows > 0: + logger.info(f"채팅 메시지 응답 업데이트 성공 - message_id: {message_id}, affected_rows: {affected_rows}") + return True + else: + logger.warning( + f"채팅 메시지 응답 업데이트 실패 - 영향받은 행이 없음: message_id={message_id}, affected_rows={affected_rows}") + return False + + except Exception as e: + logger.error(f"채팅 메시지 응답 업데이트 실패 - message_id: {message_id}, error: {str(e)}") + # 업데이트 실패해도 예외를 발생시키지 않고 False만 반환 + return False + + @staticmethod + async def get_chat_history_by_user_id(user_id: int) -> List[Dict[str, Any]]: + """사용자 ID로 채팅 이력 조회""" + try: + simple_db = ChatRepository._get_db() + result = await simple_db.execute_query( + ChatQueries.GET_CHAT_HISTORY_BY_USER, + {"user_id": user_id} + ) + + logger.info(f"채팅 이력 조회 성공 - user_id: {user_id}, count: {len(result)}") + return result if result else [] + + except Exception as e: + logger.error(f"채팅 이력 조회 실패 - user_id: {user_id}, error: {str(e)}") + raise Exception(f"채팅 이력 조회 실패: {str(e)}") \ No newline at end of file diff --git a/app/repositories/health_repository.py b/app/repositories/health_repository.py new file mode 100644 index 0000000..75f1ebb --- /dev/null +++ b/app/repositories/health_repository.py @@ -0,0 +1,198 @@ +# app/repositories/health_repository.py +""" +HealthSync AI 건강 데이터 리포지토리 (쿼리 분리) +""" +from typing import Dict, Any, Optional, List +from app.repositories.queries import HealthQueries, UserQueries +import logging + +logger = logging.getLogger(__name__) + + +class HealthRepository: + """건강 데이터 DB 조회 리포지토리""" + + @staticmethod + def _get_db(): + """simple_db를 lazy import로 가져오기 (순환 import 방지)""" + from app.utils.database_utils import simple_db + return simple_db + + @staticmethod + async def get_latest_health_checkup_by_user_id(user_id: int) -> Optional[Dict[str, Any]]: + """사용자 ID로 최신 건강검진 데이터 조회""" + try: + simple_db = HealthRepository._get_db() + result = await simple_db.execute_query( + HealthQueries.GET_LATEST_HEALTH_CHECKUP, + {"user_id": user_id} + ) + + if result and len(result) > 0: + return result[0] + return None + + except Exception as e: + logger.error(f"건강검진 데이터 조회 실패 - user_id: {user_id}, error: {str(e)}") + raise Exception(f"건강검진 데이터 조회 실패: {str(e)}") + + @staticmethod + async def get_user_basic_info_by_id(user_id: int) -> Optional[Dict[str, Any]]: + """사용자 ID로 기본 정보 조회""" + try: + simple_db = HealthRepository._get_db() + result = await simple_db.execute_query( + UserQueries.GET_USER_BASIC_INFO, + {"user_id": user_id} + ) + + if result and len(result) > 0: + return result[0] + return None + + except Exception as e: + logger.error(f"사용자 기본정보 조회 실패 - user_id: {user_id}, error: {str(e)}") + raise Exception(f"사용자 기본정보 조회 실패: {str(e)}") + + @staticmethod + async def get_health_history_by_user_id(user_id: int, limit: int = 5) -> List[Dict[str, Any]]: + """사용자 건강검진 이력 조회""" + try: + simple_db = HealthRepository._get_db() + result = await simple_db.execute_query( + HealthQueries.GET_HEALTH_HISTORY, + {"user_id": user_id, "limit": limit} + ) + return result if result else [] + + except Exception as e: + logger.error(f"건강검진 이력 조회 실패 - user_id: {user_id}, error: {str(e)}") + raise Exception(f"건강검진 이력 조회 실패: {str(e)}") + + @staticmethod + async def get_normal_ranges_by_gender(gender_code: int = 0) -> List[Dict[str, Any]]: + """성별에 따른 정상치 기준 조회""" + try: + simple_db = HealthRepository._get_db() + result = await simple_db.execute_query( + HealthQueries.GET_NORMAL_RANGES, + {"gender_code": gender_code} + ) + return result if result else [] + + except Exception as e: + logger.error(f"정상치 기준 조회 실패 - gender_code: {gender_code}, error: {str(e)}") + raise Exception(f"정상치 기준 조회 실패: {str(e)}") + + @staticmethod + async def check_user_exists(user_id: int) -> bool: + """사용자 존재 여부 확인""" + try: + simple_db = HealthRepository._get_db() + result = await simple_db.execute_query( + UserQueries.CHECK_USER_EXISTS, + {"user_id": user_id} + ) + + if result and len(result) > 0: + return result[0]["user_count"] > 0 + return False + + except Exception as e: + logger.error(f"사용자 존재 확인 실패 - user_id: {user_id}, error: {str(e)}") + return False + + @staticmethod + async def get_user_by_google_id(google_id: str) -> Optional[Dict[str, Any]]: + """Google ID로 사용자 조회""" + try: + simple_db = HealthRepository._get_db() + result = await simple_db.execute_query( + UserQueries.GET_USER_BY_GOOGLE_ID, + {"google_id": google_id} + ) + + if result and len(result) > 0: + return result[0] + return None + + except Exception as e: + logger.error(f"Google ID 사용자 조회 실패 - google_id: {google_id}, error: {str(e)}") + raise Exception(f"Google ID 사용자 조회 실패: {str(e)}") + + @staticmethod + async def insert_health_checkup(health_data: Dict[str, Any]) -> int: + """건강검진 데이터 삽입""" + try: + simple_db = HealthRepository._get_db() + result = await simple_db.execute_insert_update( + HealthQueries.INSERT_HEALTH_CHECKUP, + health_data + ) + return result + + except Exception as e: + logger.error(f"건강검진 데이터 삽입 실패 - error: {str(e)}") + raise Exception(f"건강검진 데이터 삽입 실패: {str(e)}") + + @staticmethod + async def update_health_checkup(checkup_id: int, update_data: Dict[str, Any]) -> int: + """건강검진 데이터 업데이트""" + try: + simple_db = HealthRepository._get_db() + update_data["checkup_id"] = checkup_id + result = await simple_db.execute_insert_update( + HealthQueries.UPDATE_HEALTH_CHECKUP, + update_data + ) + return result + + except Exception as e: + logger.error(f"건강검진 데이터 업데이트 실패 - checkup_id: {checkup_id}, error: {str(e)}") + raise Exception(f"건강검진 데이터 업데이트 실패: {str(e)}") + + @staticmethod + async def insert_user(user_data: Dict[str, Any]) -> int: + """사용자 생성""" + try: + simple_db = HealthRepository._get_db() + result = await simple_db.execute_insert_update( + UserQueries.INSERT_USER, + user_data + ) + return result + + except Exception as e: + logger.error(f"사용자 생성 실패 - error: {str(e)}") + raise Exception(f"사용자 생성 실패: {str(e)}") + + @staticmethod + async def update_user_info(member_serial_number: int, user_data: Dict[str, Any]) -> int: + """사용자 정보 업데이트""" + try: + simple_db = HealthRepository._get_db() + user_data["member_serial_number"] = member_serial_number + result = await simple_db.execute_insert_update( + UserQueries.UPDATE_USER_INFO, + user_data + ) + return result + + except Exception as e: + logger.error(f"사용자 정보 업데이트 실패 - member_serial_number: {member_serial_number}, error: {str(e)}") + raise Exception(f"사용자 정보 업데이트 실패: {str(e)}") + + @staticmethod + async def update_last_login(member_serial_number: int, last_login_at: str) -> int: + """최근 로그인 시간 업데이트""" + try: + simple_db = HealthRepository._get_db() + result = await simple_db.execute_insert_update( + UserQueries.UPDATE_LAST_LOGIN, + {"member_serial_number": member_serial_number, "last_login_at": last_login_at} + ) + return result + + except Exception as e: + logger.error(f"로그인 시간 업데이트 실패 - member_serial_number: {member_serial_number}, error: {str(e)}") + raise Exception(f"로그인 시간 업데이트 실패: {str(e)}") \ No newline at end of file diff --git a/app/repositories/mission_repository.py b/app/repositories/mission_repository.py new file mode 100644 index 0000000..fbdc9fe --- /dev/null +++ b/app/repositories/mission_repository.py @@ -0,0 +1,38 @@ +# app/repositories/mission_repository.py +""" +HealthSync AI 미션 데이터 리포지토리 +""" +from typing import Dict, Any, Optional, List +from app.repositories.queries.mission_queries import MissionQueries +import logging + +logger = logging.getLogger(__name__) + + +class MissionRepository: + """미션 데이터 DB 조회 리포지토리""" + + @staticmethod + def _get_db(): + """simple_db를 lazy import로 가져오기 (순환 import 방지)""" + from app.utils.database_utils import simple_db + return simple_db + + @staticmethod + async def get_mission_by_id(mission_id: int) -> Optional[Dict[str, Any]]: + """미션 ID로 미션 정보 조회""" + try: + simple_db = MissionRepository._get_db() + result = await simple_db.execute_query( + MissionQueries.GET_USER_MISSION_BY_ID, + {"mission_id": mission_id} + ) + + if result and len(result) > 0: + logger.info(f"미션 정보 조회 성공 - mission_id: {mission_id}") + return result[0] + return None + + except Exception as e: + logger.error(f"미션 정보 조회 실패 - mission_id: {mission_id}, error: {str(e)}") + raise Exception(f"미션 정보 조회 실패: {str(e)}") \ No newline at end of file diff --git a/app/repositories/queries/__init__.py b/app/repositories/queries/__init__.py new file mode 100644 index 0000000..7259dee --- /dev/null +++ b/app/repositories/queries/__init__.py @@ -0,0 +1,20 @@ +# app/repositories/queries/__init__.py +""" +HealthSync AI 쿼리 패키지 +모든 SQL 쿼리를 한 곳에서 관리합니다. +""" +from .base_queries import BaseQueries +from .health_queries import HealthQueries +from .user_queries import UserQueries +from .chat_queries import ChatQueries +from .mission_queries import MissionQueries +from .similar_mission_queries import SimilarMissionQueries + +__all__ = [ + "BaseQueries", + "HealthQueries", + "UserQueries", + "ChatQueries", + "MissionQueries", + "SimilarMissionQueries" +] \ No newline at end of file diff --git a/app/repositories/queries/base_queries.py b/app/repositories/queries/base_queries.py new file mode 100644 index 0000000..d50420a --- /dev/null +++ b/app/repositories/queries/base_queries.py @@ -0,0 +1,45 @@ +# app/repositories/queries/base_queries.py +""" +HealthSync AI 기본 시스템 쿼리 모음 +""" + + +class BaseQueries: + """기본 시스템 쿼리""" + + # 데이터베이스 연결 테스트 + CONNECTION_TEST = "SELECT 1" + + DATABASE_VERSION = "SELECT version()" + + CURRENT_DATABASE = "SELECT current_database()" + + CURRENT_USER = "SELECT current_user" + + # 테이블 목록 조회 + LIST_TABLES = """ + SELECT table_name, + table_schema, + table_type + FROM information_schema.tables + WHERE table_schema NOT IN ('information_schema', 'pg_catalog') + ORDER BY table_schema, table_name LIMIT 20 \ + """ + + # 테이블 컬럼 정보 조회 + GET_TABLE_COLUMNS = """ + SELECT column_name + FROM information_schema.columns + WHERE table_name = :table_name + ORDER BY ordinal_position \ + """ + + # 테이블 데이터 조회 (동적 쿼리 - 주의해서 사용) + @staticmethod + def get_table_data_query(table_name: str, limit: int = 5) -> str: + """테이블 데이터 조회 쿼리 생성 (SQL 인젝션 방지를 위한 검증 필요)""" + # 테이블 이름 검증 + if not table_name.replace('_', '').replace('-', '').isalnum(): + raise ValueError("잘못된 테이블 이름입니다.") + + return f"SELECT * FROM {table_name} LIMIT {limit}" \ No newline at end of file diff --git a/app/repositories/queries/chat_queries.py b/app/repositories/queries/chat_queries.py new file mode 100644 index 0000000..f1859e3 --- /dev/null +++ b/app/repositories/queries/chat_queries.py @@ -0,0 +1,45 @@ +# app/repositories/queries/chat_queries.py +""" +HealthSync AI 채팅 관련 쿼리 모음 +""" + + +class ChatQueries: + """채팅 메시지 관련 쿼리""" + + # 채팅 메시지 저장 및 ID 반환 (RETURNING 사용) + INSERT_CHAT_MESSAGE_WITH_RETURN = """ + INSERT INTO intelligence_service.chat_message + (member_serial_number, message_type, message_content, response_content, created_at) + VALUES (:member_serial_number, :message_type, :message_content, :response_content, :created_at) + RETURNING message_id + """ + + # 일반 채팅 메시지 저장 + INSERT_CHAT_MESSAGE = """ + INSERT INTO intelligence_service.chat_message + (member_serial_number, message_type, message_content, response_content, created_at) + VALUES (:member_serial_number, :message_type, :message_content, :response_content, :created_at) + """ + + # 채팅 메시지 응답 내용 업데이트 + UPDATE_CHAT_MESSAGE_RESPONSE = """ + UPDATE intelligence_service.chat_message + SET response_content = :response_content, + created_at = :updated_at + WHERE message_id = :message_id + """ + + # 사용자별 채팅 이력 조회 (전체, 시간 역순) + GET_CHAT_HISTORY_BY_USER = """ + SELECT + cm.message_id, + cm.member_serial_number, + cm.message_type, + cm.message_content, + cm.response_content, + cm.created_at + FROM intelligence_service.chat_message cm + WHERE cm.member_serial_number = :user_id + ORDER BY cm.created_at DESC + """ \ No newline at end of file diff --git a/app/repositories/queries/health_queries.py b/app/repositories/queries/health_queries.py new file mode 100644 index 0000000..2c57d17 --- /dev/null +++ b/app/repositories/queries/health_queries.py @@ -0,0 +1,47 @@ +# app/repositories/queries/health_queries.py +""" +HealthSync AI 건강 관련 쿼리 모음 +""" + + +class HealthQueries: + """건강 데이터 관련 쿼리""" + + # 최신 건강검진 데이터 조회 + GET_LATEST_HEALTH_CHECKUP = """ + SELECT + hc.checkup_id, + hc.member_serial_number, + hc.reference_year, + hc.age, + hc.height, + hc.weight, + hc.bmi, + hc.waist_circumference, + hc.visual_acuity_left, + hc.visual_acuity_right, + hc.hearing_left, + hc.hearing_right, + hc.systolic_bp, + hc.diastolic_bp, + hc.fasting_glucose, + hc.total_cholesterol, + hc.triglyceride, + hc.hdl_cholesterol, + hc.ldl_cholesterol, + hc.hemoglobin, + hc.urine_protein, + hc.serum_creatinine, + hc.ast, + hc.alt, + hc.gamma_gtp, + hc.smoking_status, + hc.drinking_status, + hc.processed_at, + hc.created_at + FROM health_service.health_checkup hc + INNER JOIN user_service.user u ON hc.member_serial_number = u.member_serial_number + WHERE u.member_serial_number = :user_id + ORDER BY hc.reference_year DESC, hc.created_at DESC + LIMIT 1 + """ diff --git a/app/repositories/queries/mission_queries.py b/app/repositories/queries/mission_queries.py new file mode 100644 index 0000000..698fcc2 --- /dev/null +++ b/app/repositories/queries/mission_queries.py @@ -0,0 +1,37 @@ +# app/repositories/queries/mission_queries.py +""" +HealthSync AI 미션 관련 쿼리 모음 +""" + + +class MissionQueries: + """미션 관련 쿼리""" + + # 사용자 미션 정보 조회 + GET_USER_MISSION_BY_ID = """ + SELECT + umg.mission_id, + umg.member_serial_number, + umg.mission_name, + umg.mission_description, + umg.daily_target_count, + umg.is_active, + umg.performance_date, + umg.created_at + FROM goal_service.user_mission_goal umg + WHERE umg.mission_id = :mission_id + """ + + # 사용자별 활성 미션 목록 조회 + GET_ACTIVE_MISSIONS_BY_USER = """ + SELECT + umg.mission_id, + umg.mission_name, + umg.mission_description, + umg.daily_target_count, + umg.performance_date + FROM goal_service.user_mission_goal umg + WHERE umg.member_serial_number = :user_id + AND umg.is_active = true + ORDER BY umg.created_at DESC + """ \ No newline at end of file diff --git a/app/repositories/queries/similar_mission_queries.py b/app/repositories/queries/similar_mission_queries.py new file mode 100644 index 0000000..1aa838a --- /dev/null +++ b/app/repositories/queries/similar_mission_queries.py @@ -0,0 +1,156 @@ +# app/repositories/queries/similar_mission_queries.py +""" +HealthSync AI 유사 사용자 미션 관련 쿼리 모음 (건강 데이터 포함) +""" + + +class SimilarMissionQueries: + """유사 사용자 미션 관련 쿼리 (건강 데이터 강화)""" + + # 최근 24시간 내 미션 완료 이력 조회 (건강 데이터 포함) + GET_RECENT_MISSION_COMPLETIONS = """ + SELECT + mch.member_serial_number, + u.name, + u.occupation, + u.birth_date, + EXTRACT(YEAR FROM AGE(u.birth_date)) as age, + umg.mission_name, + umg.mission_description, + mch.daily_completed_count, + mch.completion_date, + mch.created_at, + -- 건강 데이터 추가 + hc.height, + hc.weight, + hc.bmi, + hc.waist_circumference, + hc.systolic_bp, + hc.diastolic_bp, + hc.fasting_glucose, + hc.total_cholesterol, + hc.hdl_cholesterol, + hc.ldl_cholesterol, + hc.triglyceride, + hc.ast, + hc.alt, + hc.gamma_gtp, + hc.serum_creatinine, + hc.hemoglobin, + hc.smoking_status, + hc.drinking_status + FROM goal_service.mission_completion_history mch + INNER JOIN user_service.user u ON mch.member_serial_number = u.member_serial_number + INNER JOIN goal_service.user_mission_goal umg ON mch.mission_id = umg.mission_id + LEFT JOIN health_service.health_checkup hc ON u.member_serial_number = hc.member_serial_number + WHERE mch.member_serial_number = ANY(:user_ids) + AND mch.completion_date >= CURRENT_DATE - INTERVAL '1 day' + AND mch.daily_completed_count >= mch.daily_target_count + AND (hc.reference_year IS NULL OR hc.reference_year = ( + SELECT MAX(reference_year) + FROM health_service.health_checkup + WHERE member_serial_number = u.member_serial_number + )) + ORDER BY mch.created_at DESC + LIMIT 20 + """ + + # 사용자 건강 정보 조회 (벡터 생성용) + GET_USER_HEALTH_FOR_VECTOR = """ + SELECT + u.member_serial_number, + u.name, + u.occupation, + EXTRACT(YEAR FROM AGE(u.birth_date)) as age, + u.updated_at, + hc.height, + hc.weight, + hc.bmi, + hc.waist_circumference, + hc.systolic_bp, + hc.diastolic_bp, + hc.fasting_glucose, + hc.total_cholesterol, + hc.hdl_cholesterol, + hc.ldl_cholesterol, + hc.triglyceride, + hc.ast, + hc.alt, + hc.gamma_gtp, + hc.serum_creatinine, + hc.hemoglobin, + hc.smoking_status, + hc.drinking_status + FROM user_service.user u + LEFT JOIN health_service.health_checkup hc ON u.member_serial_number = hc.member_serial_number + WHERE u.member_serial_number = :user_id + AND (hc.reference_year IS NULL OR hc.reference_year = ( + SELECT MAX(reference_year) + FROM health_service.health_checkup + WHERE member_serial_number = u.member_serial_number + )) + """ + + # 직업 코드별 이름 조회 + GET_OCCUPATION_NAME = """ + SELECT + occupation_code, + occupation_name, + category + FROM user_service.occupation_type + WHERE occupation_code = :occupation_code + """ + + # 사용자 기본 정보 조회 (여러 사용자) + GET_USERS_BASIC_INFO = """ + SELECT + u.member_serial_number, + u.name, + u.occupation, + ot.occupation_name, + EXTRACT(YEAR FROM AGE(u.birth_date)) as age + FROM user_service.user u + LEFT JOIN user_service.occupation_type ot ON u.occupation = ot.occupation_code + WHERE u.member_serial_number = ANY(:user_ids) + """ + + # 벡터 처리를 위한 모든 사용자 데이터 조회 + GET_ALL_USERS_FOR_VECTOR = """ + SELECT + u.member_serial_number, + u.name, + u.occupation, + EXTRACT(YEAR FROM AGE(u.birth_date)) as age, + u.updated_at, + hc.height, + hc.weight, + hc.bmi, + hc.waist_circumference, + hc.systolic_bp, + hc.diastolic_bp, + hc.fasting_glucose, + hc.total_cholesterol, + hc.hdl_cholesterol, + hc.ldl_cholesterol, + hc.triglyceride, + hc.ast, + hc.alt, + hc.gamma_gtp, + hc.serum_creatinine, + hc.hemoglobin, + hc.visual_acuity_left, + hc.visual_acuity_right, + hc.hearing_left, + hc.hearing_right, + hc.urine_protein, + hc.smoking_status, + hc.drinking_status + FROM user_service.user u + LEFT JOIN health_service.health_checkup hc ON u.member_serial_number = hc.member_serial_number + WHERE hc.reference_year IS NULL OR hc.reference_year = ( + SELECT MAX(reference_year) + FROM health_service.health_checkup + WHERE member_serial_number = u.member_serial_number + ) + ORDER BY u.member_serial_number + """ \ No newline at end of file diff --git a/app/repositories/queries/user_queries.py b/app/repositories/queries/user_queries.py new file mode 100644 index 0000000..1cb6d4a --- /dev/null +++ b/app/repositories/queries/user_queries.py @@ -0,0 +1,71 @@ +# app/repositories/queries/user_queries.py +""" +HealthSync AI 사용자 관련 쿼리 모음 +""" + + +class UserQueries: + """사용자 관련 쿼리""" + + # 사용자 기본 정보 조회 + GET_USER_BASIC_INFO = """ + SELECT + u.member_serial_number, + u.google_id, + u.name, + u.birth_date, + u.occupation, + EXTRACT(YEAR FROM AGE(u.birth_date)) as age, + u.created_at, + u.updated_at, + u.last_login_at + FROM user_service.user u + WHERE u.member_serial_number = :user_id + """ + + # 사용자 존재 여부 확인 + CHECK_USER_EXISTS = """ + SELECT COUNT(*) as user_count + FROM user_service.user + WHERE member_serial_number = :user_id + """ + + # Google ID로 사용자 조회 + GET_USER_BY_GOOGLE_ID = """ + SELECT + u.member_serial_number, + u.google_id, + u.name, + u.birth_date, + u.occupation, + EXTRACT(YEAR FROM AGE(u.birth_date)) as age, + u.created_at, + u.updated_at, + u.last_login_at + FROM user_service.user u + WHERE u.google_id = :google_id + """ + + # 사용자 생성 + INSERT_USER = """ + INSERT INTO user_service.user + (google_id, name, birth_date, occupation, created_at, updated_at) + VALUES (:google_id, :name, :birth_date, :occupation, :created_at, :updated_at) + """ + + # 사용자 정보 업데이트 + UPDATE_USER_INFO = """ + UPDATE user_service.user + SET name = :name, + birth_date = :birth_date, + occupation = :occupation, + updated_at = :updated_at + WHERE member_serial_number = :member_serial_number + """ + + # 최근 로그인 시간 업데이트 + UPDATE_LAST_LOGIN = """ + UPDATE user_service.user + SET last_login_at = :last_login_at + WHERE member_serial_number = :member_serial_number + """ \ No newline at end of file diff --git a/app/repositories/similar_mission_repository.py b/app/repositories/similar_mission_repository.py new file mode 100644 index 0000000..2bc5e72 --- /dev/null +++ b/app/repositories/similar_mission_repository.py @@ -0,0 +1,129 @@ +# app/repositories/similar_mission_repository.py +""" +HealthSync AI 유사 사용자 미션 데이터 리포지토리 +""" +from typing import Dict, Any, Optional, List +from app.repositories.queries.similar_mission_queries import SimilarMissionQueries +import logging + +logger = logging.getLogger(__name__) + + +class SimilarMissionRepository: + """유사 사용자 미션 데이터 DB 조회 리포지토리""" + + @staticmethod + def _get_db(): + """simple_db를 lazy import로 가져오기 (순환 import 방지)""" + from app.utils.database_utils import simple_db + return simple_db + + @staticmethod + async def get_recent_mission_completions(user_ids: List[int]) -> List[Dict[str, Any]]: + """유사 사용자들의 최근 24시간 미션 완료 이력 조회""" + try: + simple_db = SimilarMissionRepository._get_db() + + if not user_ids: + return [] + + result = await simple_db.execute_query( + SimilarMissionQueries.GET_RECENT_MISSION_COMPLETIONS, + {"user_ids": user_ids} + ) + + logger.info(f"최근 미션 완료 이력 조회 성공 - user_count: {len(user_ids)}, " + f"completion_count: {len(result)}") + return result if result else [] + + except Exception as e: + logger.error(f"최근 미션 완료 이력 조회 실패 - user_ids: {user_ids}, error: {str(e)}") + raise Exception(f"최근 미션 완료 이력 조회 실패: {str(e)}") + + @staticmethod + async def get_user_health_for_vector(user_id: int) -> Optional[Dict[str, Any]]: + """벡터 생성을 위한 사용자 건강 정보 조회""" + try: + simple_db = SimilarMissionRepository._get_db() + result = await simple_db.execute_query( + SimilarMissionQueries.GET_USER_HEALTH_FOR_VECTOR, + {"user_id": user_id} + ) + + if result and len(result) > 0: + logger.info(f"사용자 건강 정보 조회 성공 - user_id: {user_id}") + return result[0] + + logger.warning(f"사용자 건강 정보 없음 - user_id: {user_id}") + return None + + except Exception as e: + logger.error(f"사용자 건강 정보 조회 실패 - user_id: {user_id}, error: {str(e)}") + raise Exception(f"사용자 건강 정보 조회 실패: {str(e)}") + + @staticmethod + async def get_occupation_name(occupation_code: str) -> Optional[str]: + """직업 코드로 직업명 조회""" + try: + simple_db = SimilarMissionRepository._get_db() + result = await simple_db.execute_query( + SimilarMissionQueries.GET_OCCUPATION_NAME, + {"occupation_code": occupation_code} + ) + + if result and len(result) > 0: + return result[0]["occupation_name"] + + # 기본 직업명 매핑 + occupation_mapping = { + "OFF001": "사무직", + "MED001": "의료진", + "EDU001": "교육직", + "ENG001": "엔지니어", + "SRV001": "서비스직" + } + return occupation_mapping.get(occupation_code, "기타") + + except Exception as e: + logger.error(f"직업명 조회 실패 - occupation_code: {occupation_code}, error: {str(e)}") + return "기타" + + @staticmethod + async def get_users_basic_info(user_ids: List[int]) -> List[Dict[str, Any]]: + """여러 사용자의 기본 정보 조회""" + try: + simple_db = SimilarMissionRepository._get_db() + + if not user_ids: + return [] + + result = await simple_db.execute_query( + SimilarMissionQueries.GET_USERS_BASIC_INFO, + {"user_ids": user_ids} + ) + + logger.info(f"사용자 기본 정보 조회 성공 - user_count: {len(user_ids)}, " + f"found_count: {len(result)}") + return result if result else [] + + except Exception as e: + logger.error(f"사용자 기본 정보 조회 실패 - user_ids: {user_ids}, error: {str(e)}") + raise Exception(f"사용자 기본 정보 조회 실패: {str(e)}") + + + + @staticmethod + async def get_all_users_for_vector() -> List[Dict[str, Any]]: + """벡터 처리를 위한 모든 사용자 데이터 조회""" + try: + simple_db = SimilarMissionRepository._get_db() + result = await simple_db.execute_query( + SimilarMissionQueries.GET_ALL_USERS_FOR_VECTOR + ) + + logger.info(f"전체 사용자 벡터 데이터 조회 성공 - count: {len(result)}") + return result if result else [] + + except Exception as e: + logger.error(f"전체 사용자 벡터 데이터 조회 실패 - error: {str(e)}") + raise Exception(f"전체 사용자 벡터 데이터 조회 실패: {str(e)}") \ No newline at end of file diff --git a/app/services/__init__.py b/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/services/base_service.py b/app/services/base_service.py new file mode 100644 index 0000000..6ac5229 --- /dev/null +++ b/app/services/base_service.py @@ -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) diff --git a/app/services/chat_service.py b/app/services/chat_service.py new file mode 100644 index 0000000..384ff47 --- /dev/null +++ b/app/services/chat_service.py @@ -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, "정보 없음") \ No newline at end of file diff --git a/app/services/health_service.py b/app/services/health_service.py new file mode 100644 index 0000000..689732a --- /dev/null +++ b/app/services/health_service.py @@ -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, "정보 없음") \ No newline at end of file diff --git a/app/services/mission_service.py b/app/services/mission_service.py new file mode 100644 index 0000000..2ab4976 --- /dev/null +++ b/app/services/mission_service.py @@ -0,0 +1,1338 @@ +# 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 \ No newline at end of file diff --git a/app/utils/__init__.py b/app/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/utils/claude_client.py b/app/utils/claude_client.py new file mode 100644 index 0000000..18b47c5 --- /dev/null +++ b/app/utils/claude_client.py @@ -0,0 +1,98 @@ +""" +HealthSync AI Claude API 클라이언트 (공식 라이브러리 사용) +""" +import json +import logging +import asyncio +from typing import Dict, Any +import anthropic +from app.config.settings import settings + +logger = logging.getLogger(__name__) + + +class ClaudeClient: + """Claude API 호출 클라이언트 (공식 라이브러리 사용)""" + + def __init__(self): + self.api_key = settings.claude_api_key + + # API 키 검증 + if not self.api_key or self.api_key == "" or self.api_key == "your_claude_api_key_here": + raise ValueError("Claude API 키가 설정되지 않았습니다. .env 파일에 CLAUDE_API_KEY를 설정해주세요.") + + # 동기 클라이언트 초기화 + self.client = anthropic.Anthropic(api_key=self.api_key) + logger.info(f"✅ Claude API 클라이언트 초기화 완료") + + async def call_claude_api(self, prompt: str) -> str: + """Claude API 호출 (비동기 래퍼)""" + try: + logger.info(f"🚀 Claude API 호출 시작 (모델: {settings.claude_model})") + + # 동기 함수를 비동기로 실행 + def sync_call(): + return self.client.messages.create( + model=settings.claude_model, + max_tokens=settings.claude_max_tokens, + temperature=settings.claude_temperature, + messages=[ + { + "role": "user", + "content": prompt + } + ] + ) + + # 스레드 풀에서 동기 함수 실행 + message = await asyncio.get_event_loop().run_in_executor(None, sync_call) + + logger.info("✅ Claude API 호출 성공") + return message.content[0].text + + except anthropic.AuthenticationError as e: + logger.error("❌ Claude API 인증 실패 - API 키 확인 필요") + raise Exception(f"Claude API 인증 실패: {str(e)}") + + except anthropic.NotFoundError as e: + logger.error("❌ Claude API 엔드포인트 또는 모델을 찾을 수 없음") + raise Exception(f"Claude API 모델 또는 엔드포인트 오류: {str(e)}") + + except anthropic.RateLimitError as e: + logger.error("❌ Claude API 요청 한도 초과") + raise Exception(f"Claude API 요청 한도 초과: {str(e)}") + + except anthropic.APITimeoutError as e: + logger.error("⏰ Claude API 타임아웃") + raise Exception(f"Claude API 타임아웃: {str(e)}") + + except Exception as e: + logger.error(f"❌ Claude API 호출 중 예상치 못한 오류: {str(e)}") + raise Exception(f"Claude API 호출 실패: {str(e)}") + + def parse_json_response(self, response: str) -> Dict[str, Any]: + """Claude 응답을 JSON으로 파싱""" + try: + # JSON 부분만 추출 (```json ... ``` 형태로 올 수 있음) + if "```json" in response: + start = response.find("```json") + 7 + end = response.find("```", start) + json_str = response[start:end].strip() + elif "{" in response and "}" in response: + start = response.find("{") + end = response.rfind("}") + 1 + json_str = response[start:end] + else: + json_str = response + + parsed_json = json.loads(json_str) + logger.info(f"✅ Claude 응답 파싱 성공: {len(parsed_json.get('missions', []))}개 미션") + return parsed_json + + except json.JSONDecodeError as e: + logger.error(f"❌ Claude 응답 JSON 파싱 실패: {str(e)}") + logger.error(f"파싱 대상 응답: {response}") + raise Exception(f"Claude 응답 파싱 실패: {str(e)}") + except Exception as e: + logger.error(f"❌ Claude 응답 처리 중 오류: {str(e)}") + raise Exception(f"Claude 응답 처리 실패: {str(e)}") \ No newline at end of file diff --git a/app/utils/database_utils.py b/app/utils/database_utils.py new file mode 100644 index 0000000..21cb432 --- /dev/null +++ b/app/utils/database_utils.py @@ -0,0 +1,194 @@ +# app/utils/database_utils.py (execute_insert_with_return 메소드 추가) +""" +PostgreSQL 데이터베이스 유틸리티 (databases + asyncpg) - RETURNING 지원 추가 +""" +import databases +import logging +from typing import Dict, Any, List, Optional +from app.config.settings import settings +from app.repositories.queries import BaseQueries + +logger = logging.getLogger(__name__) + + +class SimpleDatabase: + """PostgreSQL 데이터베이스 연결 클래스""" + + def __init__(self): + self.database = databases.Database(settings.database_url) + self._connected = False + + async def connect(self): + """데이터베이스 연결""" + if not self._connected: + try: + await self.database.connect() + self._connected = True + logger.info("데이터베이스 연결 성공") + except Exception as e: + logger.error(f"데이터베이스 연결 실패: {str(e)}") + raise + + async def disconnect(self): + """데이터베이스 연결 해제""" + if self._connected: + try: + await self.database.disconnect() + self._connected = False + logger.info("데이터베이스 연결 해제") + except Exception as e: + logger.error(f"데이터베이스 연결 해제 실패: {str(e)}") + + async def test_connection(self) -> Dict[str, Any]: + """데이터베이스 연결 테스트""" + try: + if not self._connected: + await self.connect() + + # 기본 쿼리 실행 + test_result = await self.database.fetch_val(BaseQueries.CONNECTION_TEST) + db_version = await self.database.fetch_val(BaseQueries.DATABASE_VERSION) + current_db = await self.database.fetch_val(BaseQueries.CURRENT_DATABASE) + current_user = await self.database.fetch_val(BaseQueries.CURRENT_USER) + + return { + "status": "connected", + "test_query": test_result, + "database_name": current_db, + "username": current_user, + "host": settings.db_host, + "port": settings.db_port, + "db_version": db_version[:50] + "..." if len(db_version) > 50 else db_version + } + + except Exception as e: + logger.error(f"데이터베이스 연결 실패: {str(e)}") + return { + "status": "failed", + "error": str(e), + "error_type": type(e).__name__ + } + + async def list_tables(self) -> List[Dict[str, Any]]: + """테이블 목록 조회""" + try: + if not self._connected: + await self.connect() + + rows = await self.database.fetch_all(BaseQueries.LIST_TABLES) + + tables = [] + for row in rows: + tables.append({ + "table_name": row["table_name"], + "table_schema": row["table_schema"], + "table_type": row["table_type"] + }) + + return tables + + except Exception as e: + logger.error(f"테이블 목록 조회 실패: {str(e)}") + raise Exception(f"테이블 목록 조회 실패: {str(e)}") + + async def query_table(self, table_name: str, limit: int = 5) -> Dict[str, Any]: + """테이블 데이터 조회""" + try: + if not self._connected: + await self.connect() + + # 안전한 쿼리 실행 + query = BaseQueries.get_table_data_query(table_name, limit) + rows = await self.database.fetch_all(query) + + # 컬럼 정보 조회 + columns_result = await self.database.fetch_all( + BaseQueries.GET_TABLE_COLUMNS, + {"table_name": table_name} + ) + columns = [col["column_name"] for col in columns_result] + + # 결과 변환 + data = [] + for row in rows: + data.append(dict(row)) + + return { + "table_name": table_name, + "column_count": len(columns), + "row_count": len(data), + "columns": columns, + "data": data + } + + except Exception as e: + logger.error(f"테이블 조회 실패: {str(e)}") + raise Exception(f"테이블 '{table_name}' 조회 실패: {str(e)}") + + async def execute_query(self, query: str, values: Optional[Dict[str, Any]] = None) -> List[Dict[str, Any]]: + """쿼리 실행 (SELECT 전용)""" + try: + if not self._connected: + await self.connect() + + logger.info(f"쿼리 실행: {query[:100]}{'...' if len(query) > 100 else ''}") + + # SELECT 쿼리 실행 + rows = await self.database.fetch_all(query, values or {}) + result = [dict(row) for row in rows] + + logger.info(f"쿼리 결과: {len(result)}건 조회") + return result + + except Exception as e: + logger.error(f"쿼리 실행 실패: {str(e)}") + logger.error(f"실행 쿼리: {query}") + logger.error(f"파라미터: {values}") + raise Exception(f"쿼리 실행 실패: {str(e)}") + + async def execute_insert_update(self, query: str, values: Optional[Dict[str, Any]] = None) -> int: + """INSERT, UPDATE, DELETE 쿼리 실행""" + try: + if not self._connected: + await self.connect() + + logger.info(f"INSERT/UPDATE 실행: {query[:100]}{'...' if len(query) > 100 else ''}") + + result = await self.database.execute(query, values or {}) + + logger.info(f"INSERT/UPDATE 결과: {result}건 영향") + return result + + except Exception as e: + logger.error(f"INSERT/UPDATE 실행 실패: {str(e)}") + logger.error(f"실행 쿼리: {query}") + logger.error(f"파라미터: {values}") + raise Exception(f"INSERT/UPDATE 실행 실패: {str(e)}") + + async def execute_insert_with_return(self, query: str, values: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + """INSERT ... RETURNING 쿼리 실행""" + try: + if not self._connected: + await self.connect() + + logger.info(f"INSERT RETURNING 실행: {query[:100]}{'...' if len(query) > 100 else ''}") + + # RETURNING이 있는 INSERT는 fetch_one으로 실행 + result = await self.database.fetch_one(query, values or {}) + + if result: + result_dict = dict(result) + logger.info(f"INSERT RETURNING 결과: {result_dict}") + return result_dict + else: + raise Exception("INSERT RETURNING 실행 결과가 없음") + + except Exception as e: + logger.error(f"INSERT RETURNING 실행 실패: {str(e)}") + logger.error(f"실행 쿼리: {query}") + logger.error(f"파라미터: {values}") + raise Exception(f"INSERT RETURNING 실행 실패: {str(e)}") + + +# 전역 데이터베이스 인스턴스 +simple_db = SimpleDatabase() \ No newline at end of file diff --git a/app/utils/redis_client.py b/app/utils/redis_client.py new file mode 100644 index 0000000..df9f425 --- /dev/null +++ b/app/utils/redis_client.py @@ -0,0 +1,226 @@ +# app/utils/redis_client.py +""" +HealthSync AI Redis 캐시 클라이언트 (Azure Cache for Redis 지원) +""" +import redis.asyncio as redis +import json +import logging +from typing import Any, Optional, List +from app.config.settings import settings + +logger = logging.getLogger(__name__) + + +class RedisClient: + """Redis 캐시 연동 클라이언트 (Azure Cache for Redis 지원)""" + + def __init__(self): + self.redis_host = settings.redis_host + self.redis_port = settings.redis_port + self.redis_password = settings.redis_password + self.redis_db = settings.redis_db + self.default_ttl = settings.redis_cache_ttl + self.client = None + self._connected = False + + async def connect(self): + """Azure Cache for Redis 연결""" + if self._connected: + return + + try: + # Azure Cache for Redis 연결 설정 + if self.redis_password and self.redis_password != "": + # Azure Cache for Redis (SSL + 인증) + self.client = redis.Redis( + host=self.redis_host, + port=self.redis_port, + password=self.redis_password, + db=self.redis_db, + ssl=True, # Azure Cache는 SSL 필수 + ssl_cert_reqs=None, # SSL 인증서 검증 비활성화 + decode_responses=True, + socket_timeout=10, + socket_connect_timeout=10, + retry_on_timeout=True, + health_check_interval=30 + ) + logger.info(f"🔐 Azure Cache for Redis 연결 시도 - {self.redis_host}:{self.redis_port}") + else: + # 로컬 Redis (비SSL) + self.client = redis.Redis( + host=self.redis_host, + port=self.redis_port, + db=self.redis_db, + decode_responses=True, + socket_timeout=5, + socket_connect_timeout=5 + ) + logger.info(f"🔓 로컬 Redis 연결 시도 - {self.redis_host}:{self.redis_port}") + + # 연결 테스트 + await self.client.ping() + self._connected = True + logger.info(f"✅ Redis 클라이언트 연결 완료 - {self.redis_host}:{self.redis_port}") + + except redis.AuthenticationError as e: + logger.error(f"❌ Redis 인증 실패 - 패스워드 확인 필요: {str(e)}") + raise Exception(f"Redis 인증 실패: {str(e)}") + except redis.ConnectionError as e: + logger.error(f"❌ Redis 연결 실패 - 호스트/포트 확인 필요: {str(e)}") + raise Exception(f"Redis 연결 실패: {str(e)}") + except redis.TimeoutError as e: + logger.error(f"❌ Redis 연결 타임아웃: {str(e)}") + raise Exception(f"Redis 연결 타임아웃: {str(e)}") + except Exception as e: + logger.error(f"❌ Redis 연결 실패: {str(e)}") + raise Exception(f"Redis 연결 실패: {str(e)}") + + async def disconnect(self): + """Redis 연결 해제""" + if self.client and self._connected: + try: + await self.client.aclose() # redis.asyncio의 올바른 종료 메소드 + self._connected = False + logger.info("✅ Redis 연결 해제 완료") + except Exception as e: + logger.error(f"❌ Redis 연결 해제 실패: {str(e)}") + + async def get(self, key: str) -> Optional[Any]: + """캐시에서 값 조회""" + try: + if not self._connected: + await self.connect() + + value = await self.client.get(key) + if value: + result = json.loads(value) + logger.info(f"✅ 캐시 조회 성공 - key: {key}") + return result + else: + logger.info(f"❌ 캐시 미스 - key: {key}") + return None + + except json.JSONDecodeError as e: + logger.error(f"❌ JSON 파싱 실패 - key: {key}, error: {str(e)}") + return None + except redis.ConnectionError as e: + logger.error(f"❌ Redis 연결 오류 - key: {key}, error: {str(e)}") + return None + except Exception as e: + logger.error(f"❌ 캐시 조회 실패 - key: {key}, error: {str(e)}") + return None + + async def set(self, key: str, value: Any, ttl: Optional[int] = None) -> bool: + """캐시에 값 저장""" + try: + if not self._connected: + await self.connect() + + ttl = ttl or self.default_ttl + json_value = json.dumps(value, ensure_ascii=False, default=str) + + await self.client.setex(key, ttl, json_value) + logger.info(f"✅ 캐시 저장 성공 - key: {key}, ttl: {ttl}s") + return True + + except redis.ConnectionError as e: + logger.error(f"❌ Redis 연결 오류 - key: {key}, error: {str(e)}") + return False + except Exception as e: + logger.error(f"❌ 캐시 저장 실패 - key: {key}, error: {str(e)}") + return False + + async def delete(self, key: str) -> bool: + """캐시에서 값 삭제""" + try: + if not self._connected: + await self.connect() + + result = await self.client.delete(key) + if result: + logger.info(f"✅ 캐시 삭제 성공 - key: {key}") + return True + else: + logger.info(f"❌ 캐시 삭제 실패 (키 없음) - key: {key}") + return False + + except redis.ConnectionError as e: + logger.error(f"❌ Redis 연결 오류 - key: {key}, error: {str(e)}") + return False + except Exception as e: + logger.error(f"❌ 캐시 삭제 실패 - key: {key}, error: {str(e)}") + return False + + async def get_or_set(self, key: str, fetch_func, ttl: Optional[int] = None) -> Any: + """캐시 조회 후 없으면 fetch_func 실행하여 저장 (Cache Aside 패턴)""" + try: + # 1. 캐시에서 조회 + cached_value = await self.get(key) + if cached_value is not None: + return cached_value + + # 2. 캐시 미스 시 데이터 fetch + fresh_value = await fetch_func() + if fresh_value is not None: + # 3. 캐시에 저장 + await self.set(key, fresh_value, ttl) + return fresh_value + + return None + + except Exception as e: + logger.error(f"❌ Cache Aside 패턴 실행 실패 - key: {key}, error: {str(e)}") + # 캐시 실패 시에도 fresh_value 반환 시도 + try: + return await fetch_func() + except: + return None + + def generate_similar_users_key(self, user_id: int) -> str: + """유사 사용자 캐시 키 생성""" + return f"similar_users:{user_id}" + + def generate_mission_news_key(self, user_id: int) -> str: + """미션 소식 캐시 키 생성 (짧은 TTL용)""" + return f"mission_news:{user_id}" + + async def test_connection(self) -> dict: + """Redis 연결 테스트""" + try: + if not self._connected: + await self.connect() + + # 기본 명령 테스트 + test_key = "healthsync:connection_test" + test_value = "test_connection_success" + + await self.client.set(test_key, test_value, ex=10) # 10초 TTL + retrieved_value = await self.client.get(test_key) + await self.client.delete(test_key) + + info = await self.client.info() + + return { + "status": "connected", + "host": self.redis_host, + "port": self.redis_port, + "ssl_enabled": bool(self.redis_password), + "test_result": retrieved_value == test_value, + "redis_version": info.get("redis_version", "unknown"), + "connected_clients": info.get("connected_clients", 0), + "used_memory_human": info.get("used_memory_human", "unknown") + } + + except Exception as e: + return { + "status": "failed", + "error": str(e), + "error_type": type(e).__name__, + "host": self.redis_host, + "port": self.redis_port + } + + +# 전역 클라이언트 인스턴스 +redis_client = RedisClient() \ No newline at end of file diff --git a/app/utils/vector_client.py b/app/utils/vector_client.py new file mode 100644 index 0000000..60e9aaf --- /dev/null +++ b/app/utils/vector_client.py @@ -0,0 +1,735 @@ +# app/utils/vector_client.py +""" +HealthSync AI Pinecone 벡터DB 클라이언트 (인덱스 초기화 기능 추가) +""" +from pinecone import Pinecone, ServerlessSpec +import logging +import asyncio +import math +from typing import List, Dict, Any, Optional +from decimal import Decimal +from app.config.settings import settings + +logger = logging.getLogger(__name__) + + +class PineconeClient: + """Pinecone 벡터DB 연동 클라이언트 (인덱스 초기화 기능 추가)""" + + def __init__(self): + self.api_key = settings.pinecone_api_key + self.index_name = settings.pinecone_index_name + self.pc = None + self.index = None + self._initialized = False + self._connection_available = False + + # 벡터 차원 + self.vector_dimension = 1024 + + # 연결 설정 + self.connection_timeout = 30 + self.max_retries = 3 + + # API 키 검증 + if not self.api_key or self.api_key == "" or self.api_key == "your_pinecone_api_key_here": + logger.warning("⚠️ Pinecone API 키가 설정되지 않음 - 벡터 기능 비활성화") + self._connection_available = False + else: + self._connection_available = True + + async def is_available(self) -> bool: + """Pinecone 서비스 사용 가능 여부 확인""" + if not self._connection_available: + return False + + try: + # 간단한 연결 테스트 + if not self.pc: + self.pc = Pinecone(api_key=self.api_key) + + # 인덱스 목록 조회로 연결 테스트 + def test_connection(): + return self.pc.list_indexes() + + result = await asyncio.wait_for( + asyncio.get_event_loop().run_in_executor(None, test_connection), + timeout=10 + ) + + logger.info("✅ Pinecone 연결 테스트 성공") + return True + + except Exception as e: + logger.warning(f"⚠️ Pinecone 연결 테스트 실패: {str(e)}") + return False + + async def initialize(self): + """Pinecone 초기화 (공식 SDK v7.x 방식)""" + if self._initialized: + return True + + if not self._connection_available: + logger.warning("⚠️ Pinecone API 키 없음 - 벡터 기능 건너뜀") + return False + + if not await self.is_available(): + logger.warning("⚠️ Pinecone 연결 불가 - 벡터 기능 건너뜀") + return False + + for attempt in range(self.max_retries): + try: + logger.info(f"🔄 Pinecone 초기화 시도 ({attempt + 1}/{self.max_retries})") + + def init_pinecone(): + # Pinecone 클라이언트 생성 + pc = Pinecone(api_key=self.api_key) + + # 인덱스 존재 확인 + existing_indexes = pc.list_indexes() + index_names = [idx.name for idx in existing_indexes.indexes] + + if self.index_name not in index_names: + logger.info(f"📋 인덱스 '{self.index_name}' 생성 중...") + + # 서버리스 인덱스 생성 + pc.create_index( + name=self.index_name, + dimension=self.vector_dimension, + metric='cosine', + spec=ServerlessSpec( + cloud='aws', + region='us-east-1' + ), + deletion_protection="disabled" + ) + + logger.info(f"✅ 인덱스 '{self.index_name}' 생성 완료") + + # 인덱스 생성 후 잠시 대기 + import time + time.sleep(5) + + # 인덱스 연결 + index = pc.Index(self.index_name) + return pc, index + + # 타임아웃 내에서 초기화 + self.pc, self.index = await asyncio.wait_for( + asyncio.get_event_loop().run_in_executor(None, init_pinecone), + timeout=self.connection_timeout + ) + + # 연결 테스트 + await self._quick_connection_test() + + self._initialized = True + logger.info(f"✅ Pinecone 클라이언트 초기화 완료 - Index: {self.index_name} (1024차원)") + return True + + except asyncio.TimeoutError: + logger.warning(f"⏰ Pinecone 초기화 타임아웃 (시도 {attempt + 1}/{self.max_retries})") + if attempt < self.max_retries - 1: + await asyncio.sleep(5) + continue + else: + logger.error("❌ Pinecone 초기화 최종 실패 (타임아웃)") + return False + + except Exception as e: + logger.error(f"❌ Pinecone 초기화 실패 (시도 {attempt + 1}/{self.max_retries}): {str(e)}") + if attempt < self.max_retries - 1: + await asyncio.sleep(5) + continue + else: + logger.error("❌ Pinecone 초기화 최종 실패") + return False + + return False + + async def _quick_connection_test(self): + """빠른 연결 테스트""" + try: + def quick_test(): + return self.index.describe_index_stats() + + stats = await asyncio.wait_for( + asyncio.get_event_loop().run_in_executor(None, quick_test), + timeout=10 + ) + + vector_count = stats.get('total_vector_count', 0) + logger.info(f"✅ Pinecone 인덱스 연결 성공 - 벡터 수: {vector_count}") + + except Exception as e: + logger.warning(f"⚠️ Pinecone 인덱스 연결 테스트 실패: {str(e)}") + + async def reset_index(self) -> bool: + """인덱스 완전 초기화 (모든 벡터 삭제 후 재생성)""" + try: + if not self._connection_available: + logger.warning("⚠️ Pinecone API 키 없음 - 인덱스 초기화 불가") + return False + + logger.info(f"🔄 인덱스 '{self.index_name}' 완전 초기화 시작...") + + def reset_pinecone_index(): + # Pinecone 클라이언트 생성 + pc = Pinecone(api_key=self.api_key) + + # 기존 인덱스 삭제 + existing_indexes = pc.list_indexes() + index_names = [idx.name for idx in existing_indexes.indexes] + + if self.index_name in index_names: + logger.info(f"🗑️ 기존 인덱스 '{self.index_name}' 삭제 중...") + pc.delete_index(self.index_name) + + # 삭제 완료 대기 + import time + time.sleep(10) + logger.info(f"✅ 기존 인덱스 '{self.index_name}' 삭제 완료") + + # 새 인덱스 생성 + logger.info(f"🆕 새 인덱스 '{self.index_name}' 생성 중...") + pc.create_index( + name=self.index_name, + dimension=self.vector_dimension, + metric='cosine', + spec=ServerlessSpec( + cloud='aws', + region='us-east-1' + ), + deletion_protection="disabled" + ) + + # 인덱스 생성 완료 대기 + time.sleep(15) + logger.info(f"✅ 새 인덱스 '{self.index_name}' 생성 완료") + + # 새 인덱스 연결 + index = pc.Index(self.index_name) + return pc, index + + # 타임아웃 내에서 인덱스 초기화 + self.pc, self.index = await asyncio.wait_for( + asyncio.get_event_loop().run_in_executor(None, reset_pinecone_index), + timeout=120 # 2분 타임아웃 (인덱스 삭제/생성 시간 고려) + ) + + # 연결 테스트 + await self._quick_connection_test() + + self._initialized = True + logger.info(f"🎉 인덱스 '{self.index_name}' 완전 초기화 성공!") + return True + + except asyncio.TimeoutError: + logger.error(f"⏰ 인덱스 초기화 타임아웃 - index: {self.index_name}") + return False + except Exception as e: + logger.error(f"❌ 인덱스 초기화 실패 - index: {self.index_name}, error: {str(e)}") + return False + + async def clear_all_vectors(self) -> bool: + """모든 벡터 삭제 (인덱스는 유지)""" + try: + if not await self.initialize(): + logger.warning("⚠️ Pinecone 초기화 실패 - 벡터 삭제 불가") + return False + + logger.info("🧹 모든 벡터 삭제 시작...") + + def delete_all_vectors(): + # 모든 벡터 삭제 (네임스페이스 지정하지 않으면 전체 삭제) + return self.index.delete(delete_all=True) + + await asyncio.wait_for( + asyncio.get_event_loop().run_in_executor(None, delete_all_vectors), + timeout=60 + ) + + logger.info("✅ 모든 벡터 삭제 완료") + return True + + except Exception as e: + logger.error(f"❌ 벡터 삭제 실패: {str(e)}") + return False + + async def get_index_stats(self) -> Dict[str, Any]: + """인덱스 통계 조회""" + try: + if not await self.initialize(): + return {"status": "failed", "error": "초기화 실패"} + + def get_stats(): + return self.index.describe_index_stats() + + stats = await asyncio.wait_for( + asyncio.get_event_loop().run_in_executor(None, get_stats), + timeout=15 + ) + + result = { + "status": "success", + "total_vector_count": stats.get('total_vector_count', 0), + "dimension": stats.get('dimension', 0), + "index_fullness": stats.get('index_fullness', 0.0), + "namespaces": stats.get('namespaces', {}) + } + + logger.info(f"📊 인덱스 통계 조회 성공 - 벡터 수: {result['total_vector_count']}") + return result + + except Exception as e: + logger.error(f"❌ 인덱스 통계 조회 실패: {str(e)}") + return {"status": "failed", "error": str(e)} + + def _is_valid_number(self, value: float) -> bool: + """숫자 유효성 검증""" + try: + return not (math.isnan(value) or math.isinf(value)) + except (TypeError, ValueError): + return False + + def _safe_float_conversion(self, value: Any, default: float = 0.0) -> float: + """안전한 float 변환""" + try: + if value is None: + return default + elif isinstance(value, Decimal): + result = float(value) + elif isinstance(value, (int, float)): + result = float(value) + elif isinstance(value, str): + try: + result = float(value) + except (ValueError, TypeError): + return default + else: + return default + + if not self._is_valid_number(result): + return default + + return result + except Exception: + return default + + def _safe_int_conversion(self, value: Any, default: int = 0) -> int: + """안전한 int 변환""" + try: + if value is None: + return default + elif isinstance(value, Decimal): + return int(value) + elif isinstance(value, (int, float)): + return int(value) + elif isinstance(value, str): + try: + return int(float(value)) + except (ValueError, TypeError): + return default + else: + return default + except Exception: + return default + + def create_user_vector(self, user_data: Dict[str, Any]) -> List[float]: + """사용자 데이터를 1024차원 벡터로 변환 (건강 데이터 중심 유사도 개선)""" + try: + vector = [] + + # 1. 나이 중심 특성 (50차원) - 나이 유사도를 높이기 위해 확장 + age = self._safe_int_conversion(user_data.get("age", 30)) + age_features = self._create_age_based_features(age) + vector.extend(age_features) + + # 2. 건강 위험도 지표 (300차원) - 건강 상태 유사도 강화 + health_risk_features = self._create_health_risk_features(user_data) + vector.extend(health_risk_features) + + # 3. 주요 건강 지표별 상세 벡터 (500차원) + detailed_health_features = self._create_detailed_health_features(user_data) + vector.extend(detailed_health_features) + + # 4. 직업 특성 (100차원) - 직업 유사도 강화 + occupation_features = self._create_occupation_features(user_data.get("occupation", "OFF001")) + vector.extend(occupation_features) + + # 5. 생활습관 패턴 (74차원) + lifestyle_features = self._create_lifestyle_features(user_data) + vector.extend(lifestyle_features) + + # 6. 1024차원 맞추기 + while len(vector) < self.vector_dimension: + vector.append(0.0) + vector = vector[:self.vector_dimension] + + # 7. 유효성 검증 + validated_vector = [] + for v in vector: + float_val = self._safe_float_conversion(v, 0.0) + validated_vector.append(float_val) + + logger.info(f"✅ 건강 중심 사용자 벡터 생성 완료 (1024차원) - " + f"user_id: {user_data.get('member_serial_number')}, " + f"age: {age}, occupation: {user_data.get('occupation')}, " + f"bmi: {self._safe_float_conversion(user_data.get('bmi'))}") + + return validated_vector + + except Exception as e: + logger.error(f"❌ 사용자 벡터 생성 실패: {str(e)}") + return [0.0] * self.vector_dimension + + def _create_age_based_features(self, age: int) -> List[float]: + """나이 기반 특성 생성 (50차원) - 연령대별 유사도 강화""" + features = [] + + # 연령대 구간별 특성 + age_ranges = [ + (20, 25), (25, 30), (30, 35), (35, 40), (40, 45), + (45, 50), (50, 55), (55, 60), (60, 65), (65, 70) + ] + + for start_age, end_age in age_ranges: + if start_age <= age < end_age: + # 해당 연령대에 높은 값 + similarity = 1.0 - abs(age - (start_age + end_age) / 2) / 5 + features.extend([max(0.0, similarity)] * 5) + else: + # 다른 연령대에는 거리 기반 유사도 + mid_age = (start_age + end_age) / 2 + distance = abs(age - mid_age) + similarity = max(0.0, 1.0 - distance / 20) + features.extend([similarity] * 5) + + return features + + def _create_health_risk_features(self, user_data: Dict[str, Any]) -> List[float]: + """건강 위험도 특성 생성 (300차원) - 건강 상태 유사도 강화""" + features = [] + + # 주요 건강 위험 지표들 + health_indicators = { + 'bmi': {'normal': (18.5, 25), 'weight': 30}, + 'systolic_bp': {'normal': (90, 140), 'weight': 25}, + 'diastolic_bp': {'normal': (60, 90), 'weight': 25}, + 'fasting_glucose': {'normal': (70, 100), 'weight': 35}, + 'total_cholesterol': {'normal': (120, 200), 'weight': 30}, + 'hdl_cholesterol': {'normal': (40, 100), 'weight': 20}, + 'ldl_cholesterol': {'normal': (0, 100), 'weight': 25}, + 'triglyceride': {'normal': (50, 150), 'weight': 20}, + 'ast': {'normal': (10, 40), 'weight': 15}, + 'alt': {'normal': (10, 40), 'weight': 15}, + 'gamma_gtp': {'normal': (10, 60), 'weight': 15}, + 'hemoglobin': {'normal': (12, 16), 'weight': 10} + } + + for indicator, config in health_indicators.items(): + value = self._safe_float_conversion(user_data.get(indicator), 0.0) + normal_min, normal_max = config['normal'] + weight = config['weight'] + + # 정상/위험 구간별 특성 생성 + risk_features = self._calculate_health_risk_pattern(value, normal_min, normal_max, weight) + features.extend(risk_features) + + return features + + def _calculate_health_risk_pattern(self, value: float, normal_min: float, normal_max: float, dim_count: int) -> \ + List[float]: + """건강 지표별 위험도 패턴 계산""" + pattern = [] + + if value == 0.0: # 데이터 없음 + pattern = [0.0] * dim_count + elif normal_min <= value <= normal_max: # 정상 범위 + normal_score = 1.0 - abs(value - (normal_min + normal_max) / 2) / ((normal_max - normal_min) / 2) + pattern = [normal_score] * dim_count + else: # 위험 범위 + if value < normal_min: # 낮음 + risk_score = (normal_min - value) / normal_min + else: # 높음 + risk_score = (value - normal_max) / normal_max + + risk_score = min(1.0, max(0.0, risk_score)) + pattern = [risk_score] * dim_count + + return pattern + + def _create_detailed_health_features(self, user_data: Dict[str, Any]) -> List[float]: + """상세 건강 특성 생성 (500차원)""" + features = [] + + # 세부 건강 지표들을 더 정교하게 벡터화 + detailed_metrics = [ + 'height', 'weight', 'waist_circumference', + 'visual_acuity_left', 'visual_acuity_right', + 'hearing_left', 'hearing_right', + 'serum_creatinine', 'urine_protein' + ] + + # 각 지표당 약 55차원 할당 + for metric in detailed_metrics: + value = self._safe_float_conversion(user_data.get(metric), 0.0) + + # 값의 범위별 분포 특성 생성 + metric_features = [] + for i in range(55): + # 다양한 스케일로 특성 생성 + scale_factor = (i + 1) / 10 + normalized_value = min(1.0, value / (100 * scale_factor)) if value > 0 else 0.0 + metric_features.append(normalized_value) + + features.extend(metric_features) + + # 나머지 차원 채우기 + remaining_dims = 500 - len(features) + features.extend([0.0] * max(0, remaining_dims)) + + return features[:500] + + def _create_occupation_features(self, occupation: str) -> List[float]: + """직업 특성 생성 (100차원) - 직업 유사도 강화""" + features = [] + + # 직업별 건강 위험 프로필 (강화된 직업 특성) + occupation_health_profiles = { + "OFF001": { # 사무직 + "sedentary_risk": 0.9, # 좌식 위험도 강화 + "stress_level": 0.7, # 스트레스 수준 + "exercise_need": 0.9, # 운동 필요도 + "eye_strain": 0.9, # 눈 피로도 + "metabolic_risk": 0.7 # 대사 위험도 + }, + "ENG001": { # IT직군/엔지니어 + "sedentary_risk": 0.95, # 사무직보다 더 높은 좌식 위험 + "stress_level": 0.8, # 높은 스트레스 + "exercise_need": 0.95, # 높은 운동 필요도 + "eye_strain": 0.95, # 높은 눈 피로도 + "metabolic_risk": 0.8 # 높은 대사 위험도 + }, + "MED001": { # 의료진 + "sedentary_risk": 0.3, + "stress_level": 0.9, + "exercise_need": 0.6, + "eye_strain": 0.4, + "metabolic_risk": 0.4 + }, + "EDU001": { # 교육직 + "sedentary_risk": 0.6, + "stress_level": 0.6, + "exercise_need": 0.7, + "eye_strain": 0.7, + "metabolic_risk": 0.5 + }, + "SRV001": { # 서비스직 + "sedentary_risk": 0.2, + "stress_level": 0.7, + "exercise_need": 0.4, + "eye_strain": 0.3, + "metabolic_risk": 0.4 + } + } + + profile = occupation_health_profiles.get(occupation, occupation_health_profiles["OFF001"]) + + # 각 위험 요소별로 20차원씩 할당 + for risk_type, risk_value in profile.items(): + risk_features = [risk_value + (i * 0.01) for i in range(20)] + features.extend([max(0.0, min(1.0, f)) for f in risk_features]) + + return features + + def _create_lifestyle_features(self, user_data: Dict[str, Any]) -> List[float]: + """생활습관 특성 생성 (74차원)""" + features = [] + + # 흡연 상태 (37차원) + smoking_status = self._safe_int_conversion(user_data.get("smoking_status"), 0) + smoking_features = [] + for i in range(37): + if smoking_status == 0: # 비흡연 + smoking_features.append(1.0 - (i * 0.02)) + elif smoking_status == 1: # 과거 흡연 + smoking_features.append(0.5 + (i * 0.01)) + else: # 현재 흡연 + smoking_features.append((i * 0.02)) + + features.extend([max(0.0, min(1.0, f)) for f in smoking_features]) + + # 음주 상태 (37차원) + drinking_status = self._safe_int_conversion(user_data.get("drinking_status"), 0) + drinking_features = [] + for i in range(37): + if drinking_status == 0: # 비음주 + drinking_features.append(1.0 - (i * 0.02)) + else: # 음주 + drinking_features.append(i * 0.03) + + features.extend([max(0.0, min(1.0, f)) for f in drinking_features]) + + return features + + async def upsert_user_vector(self, user_id: int, user_data: Dict[str, Any]) -> bool: + """사용자 벡터를 Pinecone에 저장/업데이트 (공식 SDK v7.x)""" + try: + if not await self.initialize(): + logger.warning(f"⚠️ Pinecone 초기화 실패 - 벡터 저장 건너뜀 (user_id: {user_id})") + return False + + vector = self.create_user_vector(user_data) + + if len(vector) != self.vector_dimension: + logger.error(f"❌ 벡터 차원 불일치 - 예상: {self.vector_dimension}, 실제: {len(vector)}") + return False + + # 메타데이터 생성 (검색 및 디버깅용) + metadata = { + "user_id": user_id, + "occupation": str(user_data.get("occupation", "OFF001")), + "age": self._safe_int_conversion(user_data.get("age"), 30), + "bmi": round(self._safe_float_conversion(user_data.get("bmi"), 22.0), 2), + "systolic_bp": self._safe_int_conversion(user_data.get("systolic_bp"), 120), + "fasting_glucose": self._safe_int_conversion(user_data.get("fasting_glucose"), 90), + "total_cholesterol": self._safe_int_conversion(user_data.get("total_cholesterol"), 180), + "updated_at": str(user_data.get("updated_at", "")) + } + + try: + logger.info(f"🔄 건강 중심 벡터 저장 시도 (1024차원) - user_id: {user_id}") + + def upsert_vector(): + # 공식 SDK v7.x 방식 + return self.index.upsert( + vectors=[ + { + "id": str(user_id), + "values": vector, + "metadata": metadata + } + ] + ) + + result = await asyncio.wait_for( + asyncio.get_event_loop().run_in_executor(None, upsert_vector), + timeout=30 + ) + + logger.info(f"✅ 건강 중심 사용자 벡터 저장 성공 (1024차원) - user_id: {user_id}, " + f"age: {metadata['age']}, bmi: {metadata['bmi']}, " + f"upserted_count: {result.get('upserted_count', 1)}") + return True + + except asyncio.TimeoutError: + logger.warning(f"⏰ 벡터 저장 타임아웃 - user_id: {user_id}") + return False + + except Exception as e: + logger.warning(f"⚠️ 벡터 저장 실패 - user_id: {user_id}, error: {str(e)}") + return False + + except Exception as e: + logger.warning(f"⚠️ 사용자 벡터 저장 전체 실패 - user_id: {user_id}, error: {str(e)}") + return False + + async def search_similar_users(self, user_id: int, top_k: int = 10) -> List[int]: + """유사한 사용자 ID 목록 검색 (건강 데이터 중심 유사도)""" + try: + if not await self.initialize(): + logger.warning(f"⚠️ Pinecone 초기화 실패 - 유사 사용자 검색 건너뜀 (user_id: {user_id})") + return [] + + try: + logger.info(f"🔄 건강 중심 유사 사용자 검색 시도 - user_id: {user_id}") + + # 사용자 벡터 조회 + def fetch_vector(): + return self.index.fetch(ids=[str(user_id)]) + + user_vector_result = await asyncio.wait_for( + asyncio.get_event_loop().run_in_executor(None, fetch_vector), + timeout=15 + ) + + if str(user_id) not in user_vector_result.vectors: + logger.warning(f"⚠️ 사용자 벡터를 찾을 수 없음 - user_id: {user_id}") + return [] + + user_vector = user_vector_result.vectors[str(user_id)].values + user_metadata = user_vector_result.vectors[str(user_id)].metadata + + # 유사 벡터 검색 + def query_similar(): + return self.index.query( + vector=user_vector, + top_k=top_k + 1, + include_metadata=True + ) + + search_result = await asyncio.wait_for( + asyncio.get_event_loop().run_in_executor(None, query_similar), + timeout=15 + ) + + similar_user_ids = [] + for match in search_result.matches: + matched_user_id = int(match.id) + if matched_user_id != user_id: + similar_user_ids.append(matched_user_id) + + # 유사도 디버깅 로그 + if match.metadata: + logger.debug(f"🔍 유사 사용자 발견 - user_id: {matched_user_id}, " + f"유사도: {match.score:.3f}, " + f"나이: {match.metadata.get('age')}, " + f"직업: {match.metadata.get('occupation')}, " + f"BMI: {match.metadata.get('bmi')}") + + similar_user_ids = similar_user_ids[:top_k] + + logger.info(f"✅ 건강 중심 유사 사용자 검색 완료 - user_id: {user_id}, " + f"found: {len(similar_user_ids)}, " + f"기준 나이: {user_metadata.get('age')}, " + f"기준 직업: {user_metadata.get('occupation')}") + return similar_user_ids + + except asyncio.TimeoutError: + logger.warning(f"⏰ 유사 사용자 검색 타임아웃 - user_id: {user_id}") + return [] + + except Exception as e: + logger.warning(f"⚠️ 유사 사용자 검색 실패 - user_id: {user_id}, error: {str(e)}") + return [] + + except Exception as e: + logger.warning(f"⚠️ 유사 사용자 검색 전체 실패 - user_id: {user_id}, error: {str(e)}") + return [] + + async def delete_user_vector(self, user_id: int) -> bool: + """사용자 벡터 삭제 (공식 SDK v7.x)""" + try: + if not await self.initialize(): + logger.warning(f"⚠️ Pinecone 초기화 실패 - 벡터 삭제 건너뜀 (user_id: {user_id})") + return False + + def delete_vector(): + return self.index.delete(ids=[str(user_id)]) + + await asyncio.wait_for( + asyncio.get_event_loop().run_in_executor(None, delete_vector), + timeout=15 + ) + + logger.info(f"✅ 사용자 벡터 삭제 완료 - user_id: {user_id}") + return True + + except Exception as e: + logger.warning(f"⚠️ 사용자 벡터 삭제 실패 - user_id: {user_id}, error: {str(e)}") + return False + + +# 전역 클라이언트 인스턴스 +pinecone_client = PineconeClient() \ No newline at end of file diff --git a/app/views/__init__.py b/app/views/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/views/chat_views.py b/app/views/chat_views.py new file mode 100644 index 0000000..b3d7149 --- /dev/null +++ b/app/views/chat_views.py @@ -0,0 +1,20 @@ +# app/views/chat_views.py +""" +HealthSync AI 챗봇 상담 뷰 (라우터 등록) +""" +from fastapi import APIRouter +from app.controllers.chat_controller import chat_controller + +# 챗봇 상담 라우터 생성 +chat_router = APIRouter( + prefix="/chat", + tags=["💬 Chat Consultation"], + responses={ + 400: {"description": "잘못된 요청입니다."}, + 404: {"description": "사용자를 찾을 수 없습니다."}, + 500: {"description": "서버 내부 오류가 발생했습니다."} + } +) + +# 챗봇 컨트롤러의 라우터 포함 +chat_router.include_router(chat_controller.router) \ No newline at end of file diff --git a/app/views/health_views.py b/app/views/health_views.py new file mode 100644 index 0000000..bfa4c03 --- /dev/null +++ b/app/views/health_views.py @@ -0,0 +1,20 @@ +# app/views/health_views.py +""" +HealthSync AI 건강 분석 뷰 (라우터 등록) +""" +from fastapi import APIRouter +from app.controllers.health_controller import health_controller + +# 건강 분석 라우터 생성 +health_router = APIRouter( + prefix="/health", + tags=["🔬 Health Analysis"], + responses={ + 400: {"description": "잘못된 요청입니다."}, + 404: {"description": "사용자를 찾을 수 없습니다."}, + 500: {"description": "서버 내부 오류가 발생했습니다."} + } +) + +# 건강 컨트롤러의 라우터 포함 +health_router.include_router(health_controller.router) \ No newline at end of file diff --git a/app/views/mission_views.py b/app/views/mission_views.py new file mode 100644 index 0000000..ff374a3 --- /dev/null +++ b/app/views/mission_views.py @@ -0,0 +1,20 @@ +# app/views/mission_views.py +""" +HealthSync AI 미션 관련 뷰 (라우터 등록) +""" +from fastapi import APIRouter +from app.controllers.mission_controller import mission_controller + +# 미션 관련 라우터 생성 +mission_router = APIRouter( + prefix="/missions", + tags=["🎯 Mission Management"], + responses={ + 400: {"description": "잘못된 요청입니다."}, + 404: {"description": "사용자 또는 미션을 찾을 수 없습니다."}, + 500: {"description": "서버 내부 오류가 발생했습니다."} + } +) + +# 미션 컨트롤러의 라우터 포함 (추천 + 축하 기능 모두 포함) +mission_router.include_router(mission_controller.router) \ No newline at end of file diff --git a/app/views/status_views.py b/app/views/status_views.py new file mode 100644 index 0000000..29a8cfa --- /dev/null +++ b/app/views/status_views.py @@ -0,0 +1,18 @@ +""" +HealthSync AI 상태체크 뷰 (라우터 등록) +""" +from fastapi import APIRouter +from app.controllers.status_controller import status_controller + +# 헬스체크 라우터 생성 +status_router = APIRouter( + prefix="/status", + tags=["🏥 Status Check"], + responses={ + 404: {"description": "엔드포인트를 찾을 수 없습니다."}, + 500: {"description": "서버 내부 오류가 발생했습니다."} + } +) + +# 상태체크 컨트롤러의 라우터 포함 +status_router.include_router(status_controller.router) diff --git a/deployment/manifest/deployment/intelligence-service-deployment.yaml b/deployment/manifest/deployment/intelligence-service-deployment.yaml new file mode 100644 index 0000000..536581e --- /dev/null +++ b/deployment/manifest/deployment/intelligence-service-deployment.yaml @@ -0,0 +1,168 @@ +# ================================================== +# deployment/manifest/deployment/intelligence-service-deployment.yaml +# ================================================== +apiVersion: apps/v1 +kind: Deployment +metadata: + name: intelligence-service + namespace: team1tier-healthsync-intelligence-ns + labels: + app: intelligence-service + component: backend + tier: api +spec: + replicas: 2 + strategy: + type: RollingUpdate + rollingUpdate: + maxSurge: 1 + maxUnavailable: 0 + selector: + matchLabels: + app: intelligence-service + template: + metadata: + labels: + app: intelligence-service + component: backend + environment: production + team: team1tier + annotations: + prometheus.io/scrape: "true" + prometheus.io/port: "8083" + prometheus.io/path: "/metrics" + spec: + # Image Pull Secret + imagePullSecrets: + - name: acr-secret + + # 보안 컨텍스트 + securityContext: + runAsNonRoot: true + runAsUser: 1000 + fsGroup: 1000 + + containers: + - name: intelligence-service + # 이미지는 Kustomize에서 자동으로 치환됨 + image: acrhealthsync01.azurecr.io/team1tier/intelligence-service:1.1.0 + imagePullPolicy: Always + + ports: + - name: http + containerPort: 8083 + protocol: TCP + + # 환경변수 설정 + envFrom: + - configMapRef: + name: intelligence-service-configmap + - secretRef: + name: intelligence-service-secret + + # 추가 환경변수 + env: + - name: POD_NAME + valueFrom: + fieldRef: + fieldPath: metadata.name + - name: POD_NAMESPACE + valueFrom: + fieldRef: + fieldPath: metadata.namespace + - name: NODE_NAME + valueFrom: + fieldRef: + fieldPath: spec.nodeName + + # 리소스 제한 + resources: + requests: + cpu: "200m" + memory: "512Mi" + ephemeral-storage: "1Gi" + limits: + cpu: "1000m" + memory: "1Gi" + ephemeral-storage: "2Gi" + + # Startup Probe: 초기 시작 확인 (최대 3분 대기) + # startupProbe: + # httpGet: + # path: /api/v1/health/status + # port: http + # scheme: HTTP + # initialDelaySeconds: 30 + # periodSeconds: 10 + # timeoutSeconds: 5 + # failureThreshold: 18 # 30초 + (18 * 10초) = 최대 3분 + # successThreshold: 1 + + # Readiness Probe: 트래픽 수신 준비 확인 + # readinessProbe: + # httpGet: + # path: /api/v1/health/status + # port: http + # scheme: HTTP + # initialDelaySeconds: 5 + # periodSeconds: 5 + # timeoutSeconds: 3 + # failureThreshold: 3 + # successThreshold: 1 + + # Liveness Probe: 서비스 생존 확인 + # livenessProbe: + # httpGet: + # path: /api/v1/health/status + # port: http + # scheme: HTTP + # initialDelaySeconds: 60 + # periodSeconds: 10 + # timeoutSeconds: 5 + # failureThreshold: 3 + # successThreshold: 1 + + # 보안 컨텍스트 + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: false + runAsNonRoot: true + runAsUser: 1000 + capabilities: + drop: + - ALL + + # 볼륨 마운트 (로그 수집용) + volumeMounts: + - name: tmp-volume + mountPath: /tmp + - name: cache-volume + mountPath: /app/cache + + # 볼륨 정의 + volumes: + - name: tmp-volume + emptyDir: {} + - name: cache-volume + emptyDir: {} + + # DNS 설정 + dnsPolicy: ClusterFirst + + # 재시작 정책 + restartPolicy: Always + + # 스케줄링 설정 + affinity: + podAntiAffinity: + preferredDuringSchedulingIgnoredDuringExecution: + - weight: 100 + podAffinityTerm: + labelSelector: + matchExpressions: + - key: app + operator: In + values: + - intelligence-service + topologyKey: kubernetes.io/hostname + diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..d6b7dc1 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,14 @@ +# requirements.txt - 공식 Pinecone 패키지 +fastapi==0.104.1 +uvicorn[standard]==0.24.0 +pydantic>=2.8.0 +pydantic-settings>=2.4.0 +python-dotenv==1.0.0 +pytest==7.4.3 +pytest-asyncio==0.21.1 +httpx==0.25.2 +psutil==5.9.6 +anthropic>=0.40.0 +databases[postgresql]==0.8.0 +pinecone>=7.0.0 +redis==5.0.1 \ No newline at end of file diff --git a/run.py b/run.py new file mode 100644 index 0000000..08b15af --- /dev/null +++ b/run.py @@ -0,0 +1,25 @@ +""" +HealthSync AI 서버 실행 스크립트 +""" +import uvicorn +from app.main import app +from app.config.settings import settings + + +def main(): + """메인 실행 함수""" + print(f"🚀 {settings.app_name} 서버 시작...") + print(f"📍 주소: http://{settings.host}:{settings.port}") + print(f"📖 API 문서: http://{settings.host}:{settings.port}/api/intelligence/docs") + + uvicorn.run( + "app.main:app", + host=settings.host, + port=settings.port, + reload=settings.debug, + log_level=settings.log_level.lower() + ) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29