commit 00eae37abc97565a91117ed318d456939c9f0a9c Author: P82288200 Date: Fri Jun 20 05:51:38 2025 +0000 feat : initial commit diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..1a881e9 --- /dev/null +++ b/.env.example @@ -0,0 +1,28 @@ +# .env.example +# HealthSync Motivator Batch 환경설정 샘플 + +# 애플리케이션 설정 +APP_NAME=HealthSync Motivator Batch +APP_VERSION=1.0.0 +DEBUG=True + +# PostgreSQL 데이터베이스 설정 +DB_HOST=localhost +DB_PORT=5432 +DB_NAME=healthsync_ai +DB_USERNAME=postgres +DB_PASSWORD=your_database_password_here + +# Claude AI API 설정 +CLAUDE_API_KEY=your_claude_api_key_here +CLAUDE_MODEL=claude-3-5-sonnet-20241022 +CLAUDE_MAX_TOKENS=150 +CLAUDE_TEMPERATURE=0.7 +CLAUDE_TIMEOUT=30 + +# 배치 설정 +BATCH_SIZE=100 +MAX_RETRIES=3 + +# 로깅 설정 +LOG_LEVEL=INFO \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b3dacee --- /dev/null +++ b/.gitignore @@ -0,0 +1,56 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Virtual Environment +venv/ +env/ +ENV/ +.venv/ + +# Environment variables +.env +.env.local +.env.production + +# IDE +.vscode/ +.idea/ +*.swp +*.swo + +# Logs +*.log +logs/ + +# Database +*.db +*.sqlite3 +healthsync_ai.db + +# OS +.DS_Store +Thumbs.db + +# HealthSync AI specific +temp/ +uploads/ +cache/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..9976d50 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,27 @@ +FROM python:3.11-slim + +# 작업 디렉토리 설정 +WORKDIR /app + +# 시스템 패키지 업데이트 및 필수 패키지 설치 +RUN apt-get update && apt-get install -y \ + gcc \ + libpq-dev \ + && rm -rf /var/lib/apt/lists/* + +# Python 의존성 설치 +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# 애플리케이션 코드 복사 +COPY app/ ./app/ + +# 실행 권한 부여 +RUN chmod +x app/batch_runner.py + +# 환경변수 설정 +ENV PYTHONPATH=/app +ENV PYTHONUNBUFFERED=1 + +# 기본 명령어 (배치 실행) +CMD ["python", "app/batch_runner.py"] \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..79bc54e --- /dev/null +++ b/README.md @@ -0,0 +1,40 @@ +# HealthSync Motivator Batch Service + +AI 기반 미션 독려 메시지 생성 배치 서비스 + +## 🎯 서비스 개요 + +사용자가 완료하지 않은 건강 미션을 조회하여 Claude AI를 통해 개인화된 독려 메시지를 생성하고, 채팅 DB에 저장하는 배치 서비스입니다. + +## 🏗️ 아키텍처 +``` +📁 motivator-batch-service/ +├── app/ +│ ├── batch_runner.py # 메인 실행파일 (크론탭용) +│ ├── config/ # 환경설정 +│ ├── models/ # 데이터 모델 +│ ├── services/ # 비즈니스 로직 +│ ├── repositories/ # 데이터베이스 쿼리 +│ └── utils/ # 유틸리티 +``` + +## 🔄 배치 처리 플로우 + +1. **활성 사용자 조회**: 최근 30일 내 로그인한 사용자 +2. **미완료 미션 조회**: 오늘 완료되지 않은 미션 목록 +3. **독려 메시지 생성**: Claude AI를 통한 개인화된 메시지 생성 +4. **채팅 DB 저장**: `intelligence_service.chat_message` 테이블에 저장 + +## 🚀 실행 방법 + +### 로컬 실행 +```bash +# 환경설정 파일 생성 +cp .env.example .env +# .env 파일 수정 (API 키, DB 정보 등) + +# 의존성 설치 +pip install -r requirements.txt + +# 배치 실행 +python app/batch_runner.py \ No newline at end of file diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..0737ea9 --- /dev/null +++ b/app/__init__.py @@ -0,0 +1,7 @@ +""" +HealthSync AI Motivator Batch Service +독려 메시지 생성 배치 서비스 +""" +__version__ = "1.0.0" +__title__ = "HealthSync Motivator Batch" +__description__ = "AI 기반 미션 독려 메시지 생성 배치 서비스" \ No newline at end of file diff --git a/app/batch_runner.py b/app/batch_runner.py new file mode 100644 index 0000000..e6aad94 --- /dev/null +++ b/app/batch_runner.py @@ -0,0 +1,71 @@ +# app/batch_runner.py +""" +HealthSync Motivator Batch 메인 실행파일 (크론탭용) +""" +import asyncio +import sys +from datetime import datetime +from app.config.settings import settings +from app.utils.database_utils import db +from app.utils.logger import batch_logger +from app.services.motivation_service import motivation_service + + +async def main(): + """메인 배치 실행 함수""" + start_time = datetime.now() + batch_logger.info(f"🚀 {settings.app_name} 배치 시작 - {start_time}") + + try: + # 1. 데이터베이스 연결 테스트 + batch_logger.info("🔍 데이터베이스 연결 테스트...") + connection_ok = await db.test_connection() + if not connection_ok: + raise Exception("데이터베이스 연결 실패") + batch_logger.info("✅ 데이터베이스 연결 성공") + + # 2. 독려 메시지 배치 처리 실행 + batch_logger.info("💌 독려 메시지 배치 처리 시작...") + results = await motivation_service.process_all_users() + + # 3. 처리 결과 요약 + end_time = datetime.now() + duration = end_time - start_time + + batch_logger.info(f"🎉 배치 처리 완료!") + batch_logger.info(f"📊 처리 결과:") + batch_logger.info(f" - 전체 사용자: {results['total_users']}명") + batch_logger.info(f" - 처리 완료: {results['processed_users']}명") + batch_logger.info(f" - 메시지 발송: {results['sent_messages']}건") + batch_logger.info(f" - 건너뛴 사용자: {results['skipped_users']}명") + batch_logger.info(f" - 실패한 사용자: {results['failed_users']}명") + batch_logger.info(f"⏱️ 총 소요시간: {duration}") + + # 4. 성공 종료 + return 0 + + except Exception as e: + batch_logger.error(f"❌ 배치 처리 실패: {str(e)}") + return 1 + + finally: + # 5. 데이터베이스 연결 해제 + try: + await db.disconnect() + batch_logger.info("🔌 데이터베이스 연결 해제 완료") + except Exception as e: + batch_logger.error(f"❌ 데이터베이스 연결 해제 실패: {str(e)}") + + +if __name__ == "__main__": + """크론탭에서 실행되는 엔트리포인트""" + try: + # 비동기 메인 함수 실행 + exit_code = asyncio.run(main()) + sys.exit(exit_code) + except KeyboardInterrupt: + batch_logger.info("⚠️ 사용자에 의한 배치 중단") + sys.exit(1) + except Exception as e: + batch_logger.error(f"❌ 배치 실행 중 예상치 못한 오류: {str(e)}") + sys.exit(1) \ No newline at end of file diff --git a/app/config/__init__.py b/app/config/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/config/prompts.py b/app/config/prompts.py new file mode 100644 index 0000000..e413404 --- /dev/null +++ b/app/config/prompts.py @@ -0,0 +1,34 @@ +# app/config/prompts.py +""" +HealthSync Motivator Batch 프롬프트 설정 +""" + +class PromptConfig: + """독려 메시지 프롬프트 템플릿""" + + ENCOURAGEMENT_PROMPT = """ +당신은 건강 미션을 격려하는 따뜻한 AI 코치입니다. + +**독려 메시지 작성 원칙:** +- 정확히 1줄로 작성 (50자 내외) +- 따뜻하고 격려적인 톤 +- 적절한 이모지 2-3개 사용 +- 미션의 구체적인 내용을 반영 +- 지속적인 동기부여 메시지 포함 + +**사용자 정보:** +- 직업: {occupation} +- 미완료 미션들: {incomplete_missions} + +위 정보를 바탕으로 사용자가 미션을 완료하도록 격려하는 메시지 1줄을 작성해주세요. +메시지만 작성하고 다른 말은 하지 마세요. + +예시: +💪 오늘도 건강한 하루를 위해 미션을 완료해보세요! 작은 실천이 큰 변화를 만들어요! ✨ +🌟 바쁜 하루 중에도 잠깐의 시간을 내어 건강을 챙겨보세요! 당신의 몸이 고마워할 거예요! 💚 +🚶‍♀️ 오늘의 미션이 기다리고 있어요! 건강한 습관으로 더 나은 내일을 만들어가요! 🌈 +""" + +def get_encouragement_prompt() -> str: + """독려 메시지 프롬프트 템플릿 반환""" + return PromptConfig.ENCOURAGEMENT_PROMPT \ No newline at end of file diff --git a/app/config/settings.py b/app/config/settings.py new file mode 100644 index 0000000..40f409e --- /dev/null +++ b/app/config/settings.py @@ -0,0 +1,52 @@ +# app/config/settings.py +""" +HealthSync Motivator Batch 환경설정 +""" +from pydantic_settings import BaseSettings +from typing import Optional +import os +from dotenv import load_dotenv + +# .env 파일 로드 +load_dotenv() + +class Settings(BaseSettings): + """Motivator Batch 애플리케이션 설정 클래스""" + + # 기본 앱 설정 + app_name: str = "HealthSync Motivator Batch" + app_version: str = "1.0.0" + debug: bool = True + + # PostgreSQL 설정 + db_host: str = "localhost" + db_port: int = 5432 + db_name: str = "healthsync_ai" + db_username: str = "postgres" + db_password: str = "password" + + # Claude AI 설정 + claude_api_key: str = "" + claude_model: str = "claude-3-5-sonnet-20241022" + claude_max_tokens: int = 150 + claude_temperature: float = 0.7 + claude_timeout: int = 30 + + # 배치 설정 + batch_size: int = 100 + max_retries: int = 3 + + # 로깅 설정 + 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}" + + class Config: + env_file = ".env" + case_sensitive = False + +# 전역 설정 인스턴스 +settings = Settings() \ No newline at end of file diff --git a/app/models/__init__.py b/app/models/__init__.py new file mode 100644 index 0000000..1062998 --- /dev/null +++ b/app/models/__init__.py @@ -0,0 +1,9 @@ +# app/models/__init__.py +""" +HealthSync Motivator Batch 모델 패키지 +""" +from .user import User +from .goal import UserMissionGoal, MissionCompletionHistory +from .chat_message import ChatMessage + +__all__ = ["User", "UserMissionGoal", "MissionCompletionHistory", "ChatMessage"] \ No newline at end of file diff --git a/app/models/chat_message.py b/app/models/chat_message.py new file mode 100644 index 0000000..602c06d --- /dev/null +++ b/app/models/chat_message.py @@ -0,0 +1,28 @@ +# app/models/chat_message.py +""" +HealthSync AI 채팅 메시지 모델 +""" +from pydantic import BaseModel, Field +from datetime import datetime +from typing import Optional +from enum import Enum + +class MessageType(str, Enum): + """메시지 타입""" + CONSULTATION = "consultation" # 상담 + CELEBRATION = "celebration" # 축하 + ENCOURAGEMENT = "encouragement" # 독려 + +class ChatMessage(BaseModel): + """채팅 메시지 모델""" + message_id: Optional[int] = Field(None, description="메시지 ID") + member_serial_number: int = Field(..., description="회원 일련번호") + message_type: str = Field(..., max_length=20, description="메시지 타입") + message_content: Optional[str] = Field(None, description="메시지 내용") + response_content: Optional[str] = Field(None, description="AI 응답 내용") + created_at: datetime = Field(default_factory=datetime.now, description="생성일시") + + class Config: + json_encoders = { + datetime: lambda v: v.isoformat() + } \ No newline at end of file diff --git a/app/models/goal.py b/app/models/goal.py new file mode 100644 index 0000000..7fe8a26 --- /dev/null +++ b/app/models/goal.py @@ -0,0 +1,40 @@ +# app/models/goal.py +""" +HealthSync Motivator Batch 목표 관련 모델 (goal_service) +""" +from pydantic import BaseModel, Field +from datetime import datetime, date +from typing import Optional + +class UserMissionGoal(BaseModel): + """사용자 미션 목표 (goal_service.user_mission_goal 테이블)""" + 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(..., description="활성 상태") + created_at: datetime = Field(..., description="생성일시") + + class Config: + json_encoders = { + datetime: lambda v: v.isoformat(), + date: lambda v: v.isoformat() + } + +class MissionCompletionHistory(BaseModel): + """미션 완료 이력 (goal_service.mission_completion_history 테이블)""" + 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(..., description="생성일시") + + class Config: + json_encoders = { + datetime: lambda v: v.isoformat(), + date: lambda v: v.isoformat() + } \ No newline at end of file diff --git a/app/models/user.py b/app/models/user.py new file mode 100644 index 0000000..6c0a2b9 --- /dev/null +++ b/app/models/user.py @@ -0,0 +1,24 @@ +# app/models/user.py +""" +HealthSync Motivator Batch 사용자 모델 (user_service.user) +""" +from pydantic import BaseModel, Field +from datetime import datetime, date +from typing import Optional + +class User(BaseModel): + """사용자 모델 (user_service.user 테이블)""" + 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(..., description="생성일시") + updated_at: datetime = Field(..., description="수정일시") + last_login_at: Optional[datetime] = Field(None, description="마지막 로그인") + + class Config: + json_encoders = { + datetime: lambda v: v.isoformat(), + date: lambda v: v.isoformat() + } \ No newline at end of file diff --git a/app/repositories/__init__.py b/app/repositories/__init__.py new file mode 100644 index 0000000..5882bee --- /dev/null +++ b/app/repositories/__init__.py @@ -0,0 +1,4 @@ +# app/repositories/__init__.py +""" +HealthSync Motivator Batch 리포지토리 패키지 +""" \ No newline at end of file diff --git a/app/repositories/queries/__init__.py b/app/repositories/queries/__init__.py new file mode 100644 index 0000000..90ea617 --- /dev/null +++ b/app/repositories/queries/__init__.py @@ -0,0 +1,10 @@ +# app/repositories/queries/__init__.py +""" +HealthSync Motivator Batch 쿼리 패키지 +""" +from .base_queries import BaseQueries +from .user_queries import UserQueries +from .goal_queries import GoalQueries +from .chat_queries import ChatQueries + +__all__ = ["BaseQueries", "UserQueries", "GoalQueries", "ChatQueries"] \ No newline at end of file diff --git a/app/repositories/queries/base_queries.py b/app/repositories/queries/base_queries.py new file mode 100644 index 0000000..0e5f321 --- /dev/null +++ b/app/repositories/queries/base_queries.py @@ -0,0 +1,13 @@ +# app/repositories/queries/base_queries.py +""" +HealthSync Motivator Batch 기본 쿼리 +""" + +class BaseQueries: + """기본 시스템 쿼리""" + + # 데이터베이스 연결 테스트 + CONNECTION_TEST = "SELECT 1" + + # 현재 시간 조회 + CURRENT_TIMESTAMP = "SELECT NOW()" \ No newline at end of file diff --git a/app/repositories/queries/chat_queries.py b/app/repositories/queries/chat_queries.py new file mode 100644 index 0000000..383b61f --- /dev/null +++ b/app/repositories/queries/chat_queries.py @@ -0,0 +1,23 @@ +# app/repositories/queries/chat_queries.py +""" +HealthSync Motivator Batch 채팅 메시지 관련 쿼리 +""" + +class ChatQueries: + """채팅 메시지 관련 쿼리""" + + # 독려 메시지 저장 (수정된 쿼리) + INSERT_ENCOURAGEMENT_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) + """ + + # 오늘 이미 독려 메시지를 받았는지 확인 + CHECK_TODAY_ENCOURAGEMENT = """ + SELECT COUNT(*) as count + FROM intelligence_service.chat_message cm + WHERE cm.member_serial_number = :user_id + AND cm.message_type = 'encouragement' + AND DATE(cm.created_at) = CURRENT_DATE + """ \ No newline at end of file diff --git a/app/repositories/queries/goal_queries.py b/app/repositories/queries/goal_queries.py new file mode 100644 index 0000000..ecd017e --- /dev/null +++ b/app/repositories/queries/goal_queries.py @@ -0,0 +1,54 @@ +# app/repositories/queries/goal_queries.py +""" +HealthSync Motivator Batch 목표/미션 관련 쿼리 +""" + +class GoalQueries: + """목표/미션 관련 쿼리""" + + # 사용자별 활성 미션 조회 + GET_ACTIVE_MISSIONS_BY_USER = """ + SELECT + umg.mission_id, + umg.member_serial_number, + 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 + AND umg.performance_date = CURRENT_DATE + """ + + # 오늘 완료된 미션 조회 + GET_TODAY_COMPLETED_MISSIONS = """ + SELECT + mch.mission_id, + mch.member_serial_number, + mch.daily_target_count, + mch.daily_completed_count + FROM goal_service.mission_completion_history mch + WHERE mch.member_serial_number = :user_id + AND mch.completion_date = CURRENT_DATE + AND mch.daily_completed_count >= mch.daily_target_count + """ + + # 미완료 미션 조회 (오늘 활성 미션 중 완료되지 않은 것들) + GET_INCOMPLETE_MISSIONS = """ + SELECT + umg.mission_id, + umg.mission_name, + umg.mission_description, + mch.daily_completed_count, + umg.daily_target_count, + mch.completion_date + FROM goal_service.user_mission_goal umg + LEFT JOIN goal_service.mission_completion_history mch + ON umg.mission_id = mch.mission_id + AND umg.member_serial_number = mch.member_serial_number + AND mch.completion_date = CURRENT_DATE + WHERE umg.member_serial_number = :user_id + AND ((mch.completion_date = CURRENT_DATE AND mch.daily_completed_count < mch.daily_target_count) + OR mch.completion_date IS NULL); + """ \ No newline at end of file diff --git a/app/repositories/queries/user_queries.py b/app/repositories/queries/user_queries.py new file mode 100644 index 0000000..36afd35 --- /dev/null +++ b/app/repositories/queries/user_queries.py @@ -0,0 +1,28 @@ +# app/repositories/queries/user_queries.py +""" +HealthSync Motivator Batch 사용자 관련 쿼리 +""" + +class UserQueries: + """사용자 관련 쿼리""" + + # 활성 사용자 목록 조회 + GET_ACTIVE_USERS = """ + SELECT + u.member_serial_number, + u.name, + u.occupation + FROM user_service.user u + WHERE u.last_login_at >= NOW() - INTERVAL '30 days' + ORDER BY u.member_serial_number + """ + + # 특정 사용자 정보 조회 + GET_USER_BY_ID = """ + SELECT + u.member_serial_number, + u.name, + u.occupation + FROM user_service.user u + WHERE u.member_serial_number = :user_id + """ \ No newline at end of file diff --git a/app/services/__init__.py b/app/services/__init__.py new file mode 100644 index 0000000..b344ff7 --- /dev/null +++ b/app/services/__init__.py @@ -0,0 +1,4 @@ +# app/services/__init__.py +""" +HealthSync Motivator Batch 서비스 패키지 +""" \ No newline at end of file diff --git a/app/services/motivation_service.py b/app/services/motivation_service.py new file mode 100644 index 0000000..084e788 --- /dev/null +++ b/app/services/motivation_service.py @@ -0,0 +1,165 @@ +# app/services/motivation_service.py +""" +HealthSync Motivator Batch 독려 메시지 생성 서비스 +""" +import asyncio +from typing import Dict, Any, List +from datetime import datetime +from app.utils.database_utils import db +from app.utils.claude_client import claude_client +from app.utils.logger import batch_logger +from app.config.prompts import get_encouragement_prompt +from app.repositories.queries import UserQueries, GoalQueries, ChatQueries +from app.models.chat_message import MessageType + + +class MotivationService: + """독려 메시지 생성 비즈니스 로직 서비스""" + + def __init__(self): + self.logger = batch_logger + + async def process_all_users(self) -> Dict[str, int]: + """모든 활성 사용자에 대해 독려 메시지 처리""" + try: + self.logger.info("🚀 독려 메시지 배치 처리 시작") + + # 1. 활성 사용자 목록 조회 + active_users = await db.execute_query(UserQueries.GET_ACTIVE_USERS) + self.logger.info(f"📊 활성 사용자 수: {len(active_users)}명") + + # 처리 결과 카운터 + results = { + "total_users": len(active_users), + "processed_users": 0, + "sent_messages": 0, + "skipped_users": 0, + "failed_users": 0 + } + + # 2. 각 사용자별 독려 메시지 처리 + for user in active_users: + try: + user_id = user["member_serial_number"] + self.logger.info(f"👤 사용자 처리 시작: {user['name']} (ID: {user_id})") + + # 사용자별 독려 메시지 처리 + message_sent = await self._process_user_encouragement(user) + + results["processed_users"] += 1 + if message_sent: + results["sent_messages"] += 1 + else: + results["skipped_users"] += 1 + + except Exception as e: + self.logger.error(f"❌ 사용자 처리 실패 - user_id: {user.get('member_serial_number')}, error: {str(e)}") + results["failed_users"] += 1 + + self.logger.info(f"✅ 독려 메시지 배치 처리 완료: {results}") + return results + + except Exception as e: + self.logger.error(f"❌ 배치 처리 전체 실패: {str(e)}") + raise Exception(f"배치 처리 실패: {str(e)}") + + async def _process_user_encouragement(self, user: Dict[str, Any]) -> bool: + """개별 사용자 독려 메시지 처리""" + try: + user_id = user["member_serial_number"] + user_name = user["name"] + occupation = user.get("occupation", "정보 없음") + + # # 1. 오늘 이미 독려 메시지를 받았는지 확인 + # today_encouragement = await db.execute_query( + # ChatQueries.CHECK_TODAY_ENCOURAGEMENT, + # {"user_id": user_id} + # ) + # + # if today_encouragement and today_encouragement[0]["count"] > 0: + # self.logger.info(f"⏭️ 이미 독려 메시지 발송됨 - user: {user_name}") + # return False + + # 2. 미완료 미션 조회 + incomplete_missions = await db.execute_query( + GoalQueries.GET_INCOMPLETE_MISSIONS, + {"user_id": user_id} + ) + + if not incomplete_missions: + self.logger.info(f"✅ 미완료 미션 없음 - user: {user_name}") + return False + + # 3. 독려 메시지 생성 + encouragement_message = await self._generate_encouragement_message( + occupation, incomplete_missions + ) + + # 4. 채팅 DB에 저장 + await self._save_encouragement_message(user_id, encouragement_message) + + self.logger.info(f"💌 독려 메시지 발송 완료 - user: {user_name}, " + f"미완료 미션: {len(incomplete_missions)}개") + return True + + except Exception as e: + self.logger.error(f"❌ 사용자 독려 메시지 처리 실패 - user_id: {user_id}, error: {str(e)}") + raise Exception(f"사용자 독려 메시지 처리 실패: {str(e)}") + + async def _generate_encouragement_message(self, occupation: str, incomplete_missions: List[Dict[str, Any]]) -> str: + """Claude API를 통한 독려 메시지 생성""" + try: + # 미완료 미션 목록 문자열 생성 + mission_list = [] + for mission in incomplete_missions: + mission_list.append(f"- {mission['mission_name']} (목표: {mission['daily_target_count']}회)") + + missions_text = "\n".join(mission_list) + + # 프롬프트 생성 + prompt_template = get_encouragement_prompt() + formatted_prompt = prompt_template.format( + occupation=occupation, + incomplete_missions=missions_text + ) + + # Claude API 호출 + claude_response = await claude_client.call_claude_api(formatted_prompt) + + # 응답 정제 (앞뒤 공백 제거, 따옴표 제거) + encouragement_message = claude_response.strip().strip('"').strip("'") + + self.logger.info(f"💭 독려 메시지 생성 완료 - 길이: {len(encouragement_message)}자") + return encouragement_message + + except Exception as e: + self.logger.error(f"❌ 독려 메시지 생성 실패: {str(e)}") + raise Exception(f"독려 메시지 생성 실패: {str(e)}") + + async def _save_encouragement_message(self, user_id: int, message: str) -> None: + """독려 메시지를 채팅 DB에 저장 (수정된 메서드)""" + try: + # ChatMessage 모델에 맞춰 데이터 구성 + message_data = { + "member_serial_number": user_id, + "message_type": MessageType.ENCOURAGEMENT.value, # "encouragement" + "message_content": None, # 사용자 입력이 없으므로 None + "response_content": message, # AI가 생성한 독려 메시지 + "created_at": datetime.now() + } + + await db.execute_insert( + ChatQueries.INSERT_ENCOURAGEMENT_MESSAGE, + message_data + ) + + self.logger.info(f"💾 독려 메시지 DB 저장 완료 - user_id: {user_id}, " + f"message_type: {MessageType.ENCOURAGEMENT.value}") + + except Exception as e: + self.logger.error(f"❌ 독려 메시지 DB 저장 실패 - user_id: {user_id}, error: {str(e)}") + raise Exception(f"독려 메시지 DB 저장 실패: {str(e)}") + + +# 전역 서비스 인스턴스 +motivation_service = MotivationService() \ No newline at end of file diff --git a/app/utils/__init__.py b/app/utils/__init__.py new file mode 100644 index 0000000..ce6b29b --- /dev/null +++ b/app/utils/__init__.py @@ -0,0 +1,4 @@ +# app/utils/__init__.py +""" +HealthSync Motivator Batch 유틸리티 패키지 +""" \ No newline at end of file diff --git a/app/utils/claude_client.py b/app/utils/claude_client.py new file mode 100644 index 0000000..29a4ce8 --- /dev/null +++ b/app/utils/claude_client.py @@ -0,0 +1,72 @@ +# app/utils/claude_client.py +""" +HealthSync Motivator Batch Claude API 클라이언트 +""" +import logging +import asyncio +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)}") + +# 전역 클라이언트 인스턴스 +claude_client = ClaudeClient() \ No newline at end of file diff --git a/app/utils/database_utils.py b/app/utils/database_utils.py new file mode 100644 index 0000000..01277e3 --- /dev/null +++ b/app/utils/database_utils.py @@ -0,0 +1,86 @@ +# app/utils/database_utils.py +""" +HealthSync Motivator Batch 데이터베이스 유틸리티 +""" +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) -> bool: + """데이터베이스 연결 테스트""" + try: + if not self._connected: + await self.connect() + + result = await self.database.fetch_val(BaseQueries.CONNECTION_TEST) + return result == 1 + except Exception as e: + logger.error(f"데이터베이스 연결 테스트 실패: {str(e)}") + return False + + 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() + + rows = await self.database.fetch_all(query, values or {}) + return [dict(row) for row in rows] + + 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(self, query: str, values: Optional[Dict[str, Any]] = None) -> int: + """INSERT 쿼리 실행""" + try: + if not self._connected: + await self.connect() + + result = await self.database.execute(query, values or {}) + return result + + except Exception as e: + logger.error(f"INSERT 실행 실패: {str(e)}") + logger.error(f"쿼리: {query}") + logger.error(f"파라미터: {values}") + raise Exception(f"INSERT 실행 실패: {str(e)}") + + +# 전역 데이터베이스 인스턴스 +db = SimpleDatabase() \ No newline at end of file diff --git a/app/utils/logger.py b/app/utils/logger.py new file mode 100644 index 0000000..600be29 --- /dev/null +++ b/app/utils/logger.py @@ -0,0 +1,37 @@ +# app/utils/logger.py +""" +HealthSync Motivator Batch 로깅 설정 +""" +import logging +import sys +from datetime import datetime +from app.config.settings import settings + + +def setup_logger(name: str = "motivator_batch") -> logging.Logger: + """로거 설정""" + logger = logging.getLogger(name) + + # 이미 핸들러가 있으면 중복 추가 방지 + if logger.handlers: + return logger + + logger.setLevel(getattr(logging, settings.log_level.upper())) + + # 콘솔 핸들러 + console_handler = logging.StreamHandler(sys.stdout) + console_handler.setLevel(getattr(logging, settings.log_level.upper())) + + # 포맷터 + formatter = logging.Formatter( + '%(asctime)s - %(name)s - %(levelname)s - %(message)s', + datefmt='%Y-%m-%d %H:%M:%S' + ) + console_handler.setFormatter(formatter) + + logger.addHandler(console_handler) + return logger + + +# 전역 로거 +batch_logger = setup_logger() \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..49fc8ec --- /dev/null +++ b/requirements.txt @@ -0,0 +1,8 @@ +fastapi==0.104.1 +uvicorn[standard]==0.24.0 +pydantic>=2.8.0 +pydantic-settings>=2.4.0 +python-dotenv==1.0.0 +databases[postgresql]==0.8.0 +anthropic>=0.40.0 +asyncio-mqtt==0.13.0 \ No newline at end of file