feat : initial commit
Some checks failed
HealthSync Intelligence CI / build-and-push (push) Has been cancelled

This commit is contained in:
hyerimmy 2025-06-20 05:28:30 +00:00
commit 910bd902b1
72 changed files with 6758 additions and 0 deletions

49
.env.sample Normal file
View File

@ -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

166
.github/workflows/main.yml vendored Normal file
View File

@ -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"

56
.gitignore vendored Normal file
View File

@ -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/

16
=7.0.0 Normal file
View File

@ -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

40
Dockerfile Normal file
View File

@ -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"]

50
README.md Normal file
View File

@ -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**

6
app/__init__.py Normal file
View File

@ -0,0 +1,6 @@
"""
HealthSync AI - AI 기반 개인 맞춤형 건강관리 서비스
"""
__version__ = "1.0.0"
__title__ = "HealthSync AI"
__description__ = "AI 기반 개인 맞춤형 건강관리 서비스"

0
app/config/__init__.py Normal file
View File

173
app/config/prompts.py Normal file
View File

@ -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

94
app/config/settings.py Normal file
View File

@ -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()

View File

View File

@ -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))

View File

@ -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()

View File

@ -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()

View File

@ -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()

View File

@ -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()

0
app/core/__init__.py Normal file
View File

16
app/core/dependencies.py Normal file
View File

@ -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

4
app/dto/__init__.py Normal file
View File

@ -0,0 +1,4 @@
"""
HealthSync AI DTO (Data Transfer Object) 패키지
API 요청/응답 모델들을 관리합니다.
"""

View File

@ -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"]

View File

@ -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
}
}

View File

@ -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
}
}

View File

@ -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
}
}

View File

@ -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"
]

View File

@ -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분 걷기 미션 완료! 건강한 하루를 만들어가시는 모습이 정말 멋져요! 💪✨"
}
}

View File

@ -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
}
}

View File

@ -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"
}
}

View File

@ -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 개발자 직업 특성상 장시간 앉아있어 운동 부족과 스트레스가 주요 원인으로 보입니다. 규칙적인 유산소 운동과 식단 조절을 통해 충분히 개선 가능한 상태입니다."
}
}

View File

@ -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": "충분한 수분 섭취로 혈액 순환을 개선하고 신진대사를 활성화하여 전반적인 건강 상태를 향상시킵니다."
}
]
}
}

View File

@ -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
}
}

View File

@ -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"
]

View File

@ -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")

223
app/main.py Normal file
View File

@ -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 서비스 종료 완료")

0
app/models/__init__.py Normal file
View File

36
app/models/base.py Normal file
View File

@ -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()
}

View File

@ -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()
}

56
app/models/common.py Normal file
View File

@ -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="이전 페이지 존재 여부")

113
app/models/goal.py Normal file
View File

@ -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="재설정 완료 일시")

134
app/models/health.py Normal file
View File

@ -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="응답 메시지")

142
app/models/intelligence.py Normal file
View File

@ -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="다음 예약 시간")

59
app/models/user.py Normal file
View File

@ -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="토큰 만료 시간(초)")

View File

@ -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"
]

View File

@ -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)}")

View File

@ -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)}")

View File

@ -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)}")

View File

@ -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"
]

View File

@ -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}"

View File

@ -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
"""

View File

@ -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
"""

View File

@ -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
"""

View File

@ -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
"""

View File

@ -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
"""

View File

@ -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)}")

0
app/services/__init__.py Normal file
View File

View File

@ -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)

View File

@ -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, "정보 없음")

View File

@ -0,0 +1,275 @@
# app/services/health_service.py
"""
HealthSync AI 건강 데이터 서비스
"""
import asyncio
from typing import Dict, Any, Optional
from app.services.base_service import BaseService
from app.utils.claude_client import ClaudeClient
from app.config.prompts import get_health_diagnosis_prompt
from app.repositories.health_repository import HealthRepository
from app.exceptions import (
UserNotFoundException,
HealthDataNotFoundException,
DatabaseException,
ClaudeAPIException
)
class HealthService(BaseService):
"""건강 데이터 조회 서비스"""
def __init__(self):
super().__init__()
self.claude_client = ClaudeClient()
self.health_repository = HealthRepository()
async def get_latest_health_checkup(self, user_id: int) -> Dict[str, Any]:
"""사용자의 최신 건강검진 데이터 조회"""
try:
self.log_operation("get_latest_health_checkup", user_id=user_id)
# 실제 DB에서 데이터 조회
health_data = await self.health_repository.get_latest_health_checkup_by_user_id(user_id)
if not health_data:
raise HealthDataNotFoundException(user_id)
# BMI 계산 (DB에 없는 경우)
if not health_data.get("bmi") and health_data.get("height") and health_data.get("weight"):
height_m = health_data["height"] / 100
health_data["bmi"] = round(health_data["weight"] / (height_m ** 2), 1)
self.log_operation("get_latest_health_checkup_success", user_id=user_id,
checkup_year=health_data.get("reference_year"))
return health_data
except (UserNotFoundException, HealthDataNotFoundException):
# 사용자 정의 예외는 그대로 전파
raise
except Exception as e:
self.logger.error(f"건강검진 데이터 조회 실패 - user_id: {user_id}, error: {str(e)}")
raise DatabaseException(f"건강검진 데이터 조회 중 오류가 발생했습니다: {str(e)}")
async def get_user_basic_info(self, user_id: int) -> Dict[str, Any]:
"""사용자 기본 정보 조회 (직업, 나이 등)"""
try:
self.log_operation("get_user_basic_info", user_id=user_id)
# 실제 DB에서 데이터 조회
user_data = await self.health_repository.get_user_basic_info_by_id(user_id)
if not user_data:
raise UserNotFoundException(user_id)
# 생년월일에서 나이 계산 (DB 쿼리에서 계산하지만 추가 검증)
if not user_data.get("age") and user_data.get("birth_date"):
from datetime import datetime
birth_date = user_data["birth_date"]
if isinstance(birth_date, str):
birth_date = datetime.strptime(birth_date, "%Y-%m-%d").date()
today = datetime.now().date()
user_data["age"] = today.year - birth_date.year - (
(today.month, today.day) < (birth_date.month, birth_date.day))
self.log_operation("get_user_basic_info_success", user_id=user_id,
occupation=user_data.get("occupation"))
return user_data
except UserNotFoundException:
# 사용자 정의 예외는 그대로 전파
raise
except Exception as e:
self.logger.error(f"사용자 기본정보 조회 실패 - user_id: {user_id}, error: {str(e)}")
raise DatabaseException(f"사용자 정보 조회 중 오류가 발생했습니다: {str(e)}")
async def get_combined_user_health_data(self, user_id: int) -> Dict[str, Any]:
"""사용자 기본정보와 건강검진 데이터를 결합하여 반환"""
try:
# 병렬로 데이터 조회
user_data, health_data = await asyncio.gather(
self.get_user_basic_info(user_id),
self.get_latest_health_checkup(user_id)
)
# 데이터 결합
combined_data = {
"user_info": user_data,
"health_data": health_data
}
self.log_operation("get_combined_user_health_data", user_id=user_id,
has_user_data=bool(user_data), has_health_data=bool(health_data))
return combined_data
except (UserNotFoundException, HealthDataNotFoundException, DatabaseException):
# 사용자 정의 예외는 그대로 전파
raise
except Exception as e:
self.logger.error(f"사용자 종합 데이터 조회 실패 - user_id: {user_id}, error: {str(e)}")
raise DatabaseException(f"사용자 종합 데이터 조회 중 오류가 발생했습니다: {str(e)}")
async def get_three_sentence_diagnosis(self, user_id: int) -> str:
"""사용자 건강검진 데이터 기반 3줄 요약 진단"""
try:
self.log_operation("get_three_sentence_diagnosis_start", user_id=user_id)
# 1. 사용자 데이터 조회
combined_data = await self.get_combined_user_health_data(user_id)
user_data = combined_data["user_info"]
health_data = combined_data["health_data"]
# 2. 프롬프트 생성
prompt = self._build_diagnosis_prompt(user_data, health_data)
# 3. Claude API 호출
claude_response = await self.claude_client.call_claude_api(prompt)
# 4. 응답 정제 (앞뒤 공백 제거)
diagnosis = claude_response.strip()
self.log_operation("get_three_sentence_diagnosis_success", user_id=user_id,
diagnosis_length=len(diagnosis))
return diagnosis
except (UserNotFoundException, HealthDataNotFoundException, DatabaseException):
# 사용자 정의 예외는 그대로 전파
raise
except Exception as e:
# Claude API 오류
self.logger.error(f"3줄 요약 진단 실패 - user_id: {user_id}, error: {str(e)}")
raise ClaudeAPIException(f"건강 진단 분석 중 오류가 발생했습니다: {str(e)}")
async def get_combined_user_health_data(self, user_id: int) -> Dict[str, Any]:
"""사용자 기본정보와 건강검진 데이터를 결합하여 반환"""
try:
# 병렬로 데이터 조회
user_data, health_data = await asyncio.gather(
self.get_user_basic_info(user_id),
self.get_latest_health_checkup(user_id)
)
# 데이터 결합
combined_data = {
"user_info": user_data,
"health_data": health_data
}
self.log_operation("get_combined_user_health_data", user_id=user_id,
has_user_data=bool(user_data), has_health_data=bool(health_data))
return combined_data
except Exception as e:
self.logger.error(f"사용자 종합 데이터 조회 실패 - user_id: {user_id}, error: {str(e)}")
raise Exception(f"사용자 종합 데이터 조회 실패: {str(e)}")
async def get_three_sentence_diagnosis(self, user_id: int) -> str:
"""사용자 건강검진 데이터 기반 3줄 요약 진단"""
try:
self.log_operation("get_three_sentence_diagnosis_start", user_id=user_id)
# 1. 사용자 데이터 조회
combined_data = await self.get_combined_user_health_data(user_id)
user_data = combined_data["user_info"]
health_data = combined_data["health_data"]
# 2. 프롬프트 생성
prompt = self._build_diagnosis_prompt(user_data, health_data)
# 3. Claude API 호출
claude_response = await self.claude_client.call_claude_api(prompt)
# 4. 응답 정제 (앞뒤 공백 제거)
diagnosis = claude_response.strip()
self.log_operation("get_three_sentence_diagnosis_success", user_id=user_id,
diagnosis_length=len(diagnosis))
return diagnosis
except Exception as e:
self.logger.error(f"3줄 요약 진단 실패 - user_id: {user_id}, error: {str(e)}")
raise Exception(f"건강 진단 분석 실패: {str(e)}")
def _build_diagnosis_prompt(self, user_data: Dict[str, Any], health_data: Dict[str, Any]) -> str:
"""사용자 데이터를 기반으로 진단 프롬프트 생성"""
try:
prompt_template = get_health_diagnosis_prompt()
# BMI 계산
bmi = health_data.get("bmi", 0)
if not bmi and health_data.get("height") and health_data.get("weight"):
height_m = health_data["height"] / 100
bmi = round(health_data["weight"] / (height_m ** 2), 1)
# 프롬프트에 데이터 매핑
formatted_prompt = prompt_template.format(
# 사용자 기본 정보
occupation=user_data.get("occupation", "정보 없음"),
name=user_data.get("name", "정보 없음"),
age=user_data.get("age", health_data.get("age", "정보 없음")),
# 신체 정보
height=health_data.get("height", "정보 없음"),
weight=health_data.get("weight", "정보 없음"),
bmi=bmi if bmi else "정보 없음",
waist_circumference=health_data.get("waist_circumference", "정보 없음"),
# 혈압 및 혈당
systolic_bp=health_data.get("systolic_bp", "정보 없음"),
diastolic_bp=health_data.get("diastolic_bp", "정보 없음"),
fasting_glucose=health_data.get("fasting_glucose", "정보 없음"),
# 콜레스테롤
total_cholesterol=health_data.get("total_cholesterol", "정보 없음"),
hdl_cholesterol=health_data.get("hdl_cholesterol", "정보 없음"),
ldl_cholesterol=health_data.get("ldl_cholesterol", "정보 없음"),
triglyceride=health_data.get("triglyceride", "정보 없음"),
# 기타 혈액 검사
hemoglobin=health_data.get("hemoglobin", "정보 없음"),
serum_creatinine=health_data.get("serum_creatinine", "정보 없음"),
ast=health_data.get("ast", "정보 없음"),
alt=health_data.get("alt", "정보 없음"),
gamma_gtp=health_data.get("gamma_gtp", "정보 없음"),
urine_protein=health_data.get("urine_protein", "정보 없음"),
# 감각기관
visual_acuity_left=health_data.get("visual_acuity_left", "정보 없음"),
visual_acuity_right=health_data.get("visual_acuity_right", "정보 없음"),
hearing_left=health_data.get("hearing_left", "정보 없음"),
hearing_right=health_data.get("hearing_right", "정보 없음"),
# 생활습관
smoking_status=self._convert_smoking_status(health_data.get("smoking_status")),
drinking_status=self._convert_drinking_status(health_data.get("drinking_status"))
)
self.log_operation("build_diagnosis_prompt", user_id="N/A", prompt_length=len(formatted_prompt))
return formatted_prompt
except Exception as e:
self.logger.error(f"진단 프롬프트 생성 실패: {str(e)}")
raise Exception(f"진단 프롬프트 생성 실패: {str(e)}")
def _convert_smoking_status(self, status: int) -> str:
"""흡연 상태 코드를 텍스트로 변환"""
smoking_map = {
0: "비흡연",
1: "과거 흡연",
2: "현재 흡연"
}
return smoking_map.get(status, "정보 없음")
def _convert_drinking_status(self, status: int) -> str:
"""음주 상태 코드를 텍스트로 변환"""
drinking_map = {
0: "비음주",
1: "음주"
}
return drinking_map.get(status, "정보 없음")

File diff suppressed because it is too large Load Diff

0
app/utils/__init__.py Normal file
View File

View File

@ -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)}")

194
app/utils/database_utils.py Normal file
View File

@ -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()

226
app/utils/redis_client.py Normal file
View File

@ -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()

735
app/utils/vector_client.py Normal file
View File

@ -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()

0
app/views/__init__.py Normal file
View File

20
app/views/chat_views.py Normal file
View File

@ -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)

20
app/views/health_views.py Normal file
View File

@ -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)

View File

@ -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)

18
app/views/status_views.py Normal file
View File

@ -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)

View File

@ -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

14
requirements.txt Normal file
View File

@ -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

25
run.py Normal file
View File

@ -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()

0
tests/__init__.py Normal file
View File