# vector/app/main.py import os import sys # ============================================================================= # .env 파일 로딩 (다른 import보다 먼저) # ============================================================================= def is_kubernetes_env(): """Kubernetes 환경 감지""" return ( os.path.exists('/var/run/secrets/kubernetes.io/serviceaccount') or os.getenv('KUBERNETES_SERVICE_HOST') or os.getenv('ENVIRONMENT') == 'production' ) # 조건부 dotenv 로딩 if not is_kubernetes_env(): try: sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) from dotenv import load_dotenv load_dotenv() print("✅ 로컬 개발환경: .env 파일 로딩") except ImportError: print("⚠️ python-dotenv 없음, 환경변수만 사용") else: print("ℹ️ Kubernetes/Production: ConfigMap/Secret 사용") import logging from contextlib import asynccontextmanager from datetime import datetime from typing import Optional import asyncio import fastapi from fastapi import FastAPI, HTTPException, BackgroundTasks, Depends from fastapi.responses import HTMLResponse, JSONResponse from fastapi.middleware.cors import CORSMiddleware from pydantic import BaseModel, Field # 프로젝트 모듈 import from app.config.settings import settings from app.models.restaurant_models import RestaurantSearchRequest, ErrorResponse from app.models.vector_models import ( VectorBuildRequest, VectorBuildResponse, ActionRecommendationRequest, ActionRecommendationResponse, VectorDBStatusResponse, VectorDBStatus ) from app.services.restaurant_service import RestaurantService from app.services.review_service import ReviewService from app.services.vector_service import VectorService from app.services.claude_service import ClaudeService from app.utils.category_utils import extract_food_category # 로깅 설정 logging.basicConfig( level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', handlers=[logging.StreamHandler(sys.stdout)] ) logger = logging.getLogger(__name__) # 🔧 전역 변수 대신 애플리케이션 상태로 관리 app_state = { "vector_service": None, "restaurant_service": None, "review_service": None, "claude_service": None, "initialization_errors": {}, "startup_completed": False } # 추가 모델 정의 (find-reviews API용) class FindReviewsRequest(BaseModel): """리뷰 검색 요청 모델""" region: str = Field( ..., description="지역 (시군구 + 읍면동)", example="서울특별시 강남구 역삼동" ) store_name: str = Field( ..., description="가게명", example="맛있는 한식당" ) class RestaurantInfo(BaseModel): """음식점 정보 모델""" id: str = Field(description="카카오 장소 ID") place_name: str = Field(description="장소명") category_name: str = Field(description="카테고리명") address_name: str = Field(description="전체 주소") phone: str = Field(description="전화번호") place_url: str = Field(description="장소 상세페이지 URL") x: str = Field(description="X 좌표값 (경도)") y: str = Field(description="Y 좌표값 (위도)") class FindReviewsResponse(BaseModel): """리뷰 검색 응답 모델""" success: bool = Field(description="검색 성공 여부") message: str = Field(description="응답 메시지") target_store: RestaurantInfo = Field(description="대상 가게 정보") total_stores: int = Field(description="수집된 총 가게 수") total_reviews: int = Field(description="수집된 총 리뷰 수") food_category: str = Field(description="추출된 음식 카테고리") region: str = Field(description="검색 지역") @asynccontextmanager async def lifespan(app: FastAPI): """🔧 애플리케이션 생명주기 관리 - 안전한 서비스 초기화""" # 🚀 Startup 이벤트 logger.info("🚀 Vector API 서비스 시작 중...") startup_start_time = datetime.now() # 각 서비스 안전하게 초기화 services_to_init = [ ("restaurant_service", RestaurantService, "Restaurant API 서비스"), ("review_service", ReviewService, "Review API 서비스"), ("claude_service", ClaudeService, "Claude AI 서비스"), ("vector_service", VectorService, "Vector DB 서비스") # 마지막에 초기화 ] initialized_count = 0 for service_key, service_class, service_name in services_to_init: try: logger.info(f"🔧 {service_name} 초기화 중...") app_state[service_key] = service_class() logger.info(f"✅ {service_name} 초기화 완료") initialized_count += 1 except Exception as e: logger.error(f"❌ {service_name} 초기화 실패: {e}") app_state["initialization_errors"][service_key] = str(e) # 🔧 중요: 서비스 초기화 실패해도 앱은 시작 (헬스체크에서 확인) continue startup_time = (datetime.now() - startup_start_time).total_seconds() app_state["startup_completed"] = True logger.info(f"✅ Vector API 서비스 시작 완료!") logger.info(f"📊 초기화 결과: {initialized_count}/{len(services_to_init)}개 서비스 성공") logger.info(f"⏱️ 시작 소요시간: {startup_time:.2f}초") if app_state["initialization_errors"]: logger.warning(f"⚠️ 초기화 실패 서비스: {list(app_state['initialization_errors'].keys())}") yield # 🛑 Shutdown 이벤트 logger.info("🛑 Vector API 서비스 종료 중...") # 리소스 정리 for service_key in ["vector_service", "restaurant_service", "review_service", "claude_service"]: if app_state[service_key] is not None: try: # 서비스별 정리 작업이 있다면 여기서 수행 logger.info(f"🔧 {service_key} 정리 중...") except Exception as e: logger.warning(f"⚠️ {service_key} 정리 실패: {e}") finally: app_state[service_key] = None app_state["startup_completed"] = False logger.info("✅ Vector API 서비스 종료 완료") # 🔧 FastAPI 앱 초기화 (lifespan 이벤트 포함) app = FastAPI( title=settings.APP_TITLE, description=f""" {settings.APP_DESCRIPTION} **주요 기능:** - 지역과 가게명으로 대상 가게 찾기 - 동종 업체 리뷰 수집 및 분석 - Vector DB 구축 및 관리 - Claude AI 기반 액션 추천 - 영속적 Vector DB 저장 **API 연동:** - Restaurant API: {settings.get_restaurant_api_url() if hasattr(settings, 'get_restaurant_api_url') else 'N/A'} - Review API: {settings.get_review_api_url() if hasattr(settings, 'get_review_api_url') else 'N/A'} - Claude AI API: {settings.CLAUDE_MODEL} **Vector DB:** - 경로: {settings.VECTOR_DB_PATH} - 컬렉션: {settings.VECTOR_DB_COLLECTION} - 임베딩 모델: {settings.EMBEDDING_MODEL} **버전:** {settings.APP_VERSION} """, version=settings.APP_VERSION, contact={ "name": "개발팀", "email": "admin@example.com" }, lifespan=lifespan # 🔧 lifespan 이벤트 등록 ) # CORS 설정 app.add_middleware( CORSMiddleware, allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) # 🔧 Dependency Injection - 서비스 제공자들 def get_vector_service() -> VectorService: """VectorService 의존성 주입""" if app_state["vector_service"] is None: error_msg = app_state["initialization_errors"].get("vector_service") if error_msg: raise HTTPException( status_code=503, detail=f"Vector service not available: {error_msg}" ) # 런타임에 재시도 try: logger.info("🔧 VectorService 런타임 초기화 시도...") app_state["vector_service"] = VectorService() logger.info("✅ VectorService 런타임 초기화 성공") except Exception as e: logger.error(f"❌ VectorService 런타임 초기화 실패: {e}") raise HTTPException( status_code=503, detail=f"Vector service initialization failed: {str(e)}" ) return app_state["vector_service"] def get_restaurant_service() -> RestaurantService: """RestaurantService 의존성 주입""" if app_state["restaurant_service"] is None: error_msg = app_state["initialization_errors"].get("restaurant_service") if error_msg: raise HTTPException( status_code=503, detail=f"Restaurant service not available: {error_msg}" ) try: app_state["restaurant_service"] = RestaurantService() except Exception as e: raise HTTPException(status_code=503, detail=str(e)) return app_state["restaurant_service"] def get_review_service() -> ReviewService: """ReviewService 의존성 주입""" if app_state["review_service"] is None: error_msg = app_state["initialization_errors"].get("review_service") if error_msg: raise HTTPException( status_code=503, detail=f"Review service not available: {error_msg}" ) try: app_state["review_service"] = ReviewService() except Exception as e: raise HTTPException(status_code=503, detail=str(e)) return app_state["review_service"] def get_claude_service() -> ClaudeService: """ClaudeService 의존성 주입""" if app_state["claude_service"] is None: error_msg = app_state["initialization_errors"].get("claude_service") if error_msg: raise HTTPException( status_code=503, detail=f"Claude service not available: {error_msg}" ) try: app_state["claude_service"] = ClaudeService() except Exception as e: raise HTTPException(status_code=503, detail=str(e)) return app_state["claude_service"] @app.get("/", response_class=HTMLResponse, include_in_schema=False) async def root(): """메인 페이지""" # 🔧 안전한 DB 상태 조회 try: vector_service = app_state.get("vector_service") if vector_service: db_status = vector_service.get_db_status() else: db_status = { 'collection_name': settings.VECTOR_DB_COLLECTION, 'total_documents': 0, 'total_stores': 0, 'db_path': settings.VECTOR_DB_PATH, 'status': 'not_initialized' } except Exception as e: logger.warning(f"DB 상태 조회 실패: {e}") db_status = {'status': 'error', 'error': str(e)} return f""" {settings.APP_TITLE}

🍽️ {settings.APP_TITLE}

{settings.APP_DESCRIPTION}

📊 Vector DB 상태

{f'''

⚠️ 초기화 실패 서비스

''' if app_state["initialization_errors"] else ''}

🔧 시스템 구성

📚 API 문서

Swagger UI 문서 ReDoc 문서 헬스 체크 Vector DB 상태

🛠️ 사용 방법

POST /find-reviews - 리뷰 검색 및 Vector DB 저장 (본인 가게 우선)

{{
  "region": "서울특별시 강남구 역삼동",
  "store_name": "맛있는 한식당"
}}
            

POST /build-vector - Vector DB 구축

{{
  "region": "서울특별시 강남구 역삼동",
  "store_name": "맛있는 한식당",
  "force_rebuild": false
}}
            

POST /action-recommendation - 액션 추천 요청

{{
  "store_id": "12345",
  "context": "매출이 감소하고 있어서 개선이 필요합니다"
}}
            
""" @app.post("/find-reviews", response_model=FindReviewsResponse) async def find_reviews( request: FindReviewsRequest, vector_service: VectorService = Depends(get_vector_service), restaurant_service: RestaurantService = Depends(get_restaurant_service), review_service: ReviewService = Depends(get_review_service) ): """ 지역과 가게명으로 리뷰를 찾아 Vector DB에 저장합니다. 🔥 본인 가게 리뷰는 반드시 포함됩니다. (수정된 버전) """ start_time = datetime.now() logger.info(f"🔍 리뷰 검색 요청: {request.region} - {request.store_name}") try: # 1단계: 본인 가게 검색 logger.info("1단계: 본인 가게 검색 중... (최우선)") target_restaurant = await restaurant_service.find_store_by_name_and_region( request.region, request.store_name ) if not target_restaurant: logger.error(f"❌ 본인 가게를 찾을 수 없음: {request.store_name}") raise HTTPException( status_code=404, detail=f"'{request.store_name}' 가게를 찾을 수 없습니다. 가게명과 지역을 정확히 입력해주세요." ) logger.info(f"✅ 본인 가게 발견: {target_restaurant.place_name} (ID: {target_restaurant.id})") # 2단계: 동종 업체 검색 logger.info("2단계: 동종 업체 검색 중...") similar_restaurants = [] food_category = "기타" # 기본값 try: food_category = extract_food_category(target_restaurant.category_name) logger.info(f"추출된 음식 카테고리: {food_category}") similar_restaurants = await restaurant_service.find_similar_stores( request.region, food_category, settings.MAX_RESTAURANTS_PER_CATEGORY ) logger.info(f"✅ 동종 업체 {len(similar_restaurants)}개 발견") except Exception as e: logger.warning(f"⚠️ 동종 업체 검색 실패 (본인 가게는 계속 진행): {e}") # 3단계: 전체 가게 목록 구성 (본인 가게 우선 + 중복 제거) logger.info("3단계: 전체 가게 목록 구성 중...") # 본인 가게를 첫 번째로 배치 all_restaurants = [target_restaurant] # 동종 업체 추가 (개선된 중복 제거) for restaurant in similar_restaurants: if not _is_duplicate_restaurant(target_restaurant, restaurant): all_restaurants.append(restaurant) logger.info(f"✅ 전체 가게 목록 구성 완료: {len(all_restaurants)}개 (본인 가게 포함)") # 4단계: 전체 리뷰 수집 (본인 가게 우선 처리) logger.info("4단계: 리뷰 수집 중... (본인 가게 우선)") # 본인 가게 우선 처리를 위한 특별 로직 review_results = [] # 4-1: 본인 가게 리뷰 수집 (실패 시 전체 중단) try: logger.info("본인 가게 리뷰 우선 수집 중... (필수)") target_store_info, target_reviews = await review_service.collect_store_reviews( target_restaurant.id, max_reviews=settings.MAX_REVIEWS_PER_RESTAURANT * 2 # 본인 가게는 더 많이 ) if not target_store_info or not target_reviews: logger.error(f"❌ 본인 가게 리뷰 수집 실패: {target_restaurant.place_name}") raise HTTPException( status_code=422, detail=f"본인 가게 '{target_restaurant.place_name}'의 리뷰를 수집할 수 없습니다." ) # 본인 가게 결과를 첫 번째로 설정 review_results.append((target_restaurant.id, target_store_info, target_reviews)) logger.info(f"✅ 본인 가게 리뷰 수집 성공: {len(target_reviews)}개") except HTTPException: raise except Exception as e: logger.error(f"❌ 본인 가게 리뷰 수집 중 오류: {e}") raise HTTPException( status_code=500, detail=f"본인 가게 리뷰 수집 중 오류가 발생했습니다: {str(e)}" ) # 4-2: 동종 업체 리뷰 수집 (실패해도 본인 가게는 유지) if len(all_restaurants) > 1: # 본인 가게 외에 다른 가게가 있는 경우 try: logger.info(f"동종 업체 리뷰 수집 중... ({len(all_restaurants) - 1}개)") # 본인 가게 제외한 동종 업체만 수집 similar_restaurants_only = all_restaurants[1:] similar_results = await review_service.collect_multiple_stores_reviews(similar_restaurants_only) # 동종 업체 결과 추가 review_results.extend(similar_results) similar_reviews_count = sum(len(reviews) for _, _, reviews in similar_results) logger.info(f"✅ 동종 업체 리뷰 수집 완료: {len(similar_results)}개 가게, {similar_reviews_count}개 리뷰") except Exception as e: logger.warning(f"⚠️ 동종 업체 리뷰 수집 실패 (본인 가게는 유지): {e}") else: logger.info("동종 업체가 없어 본인 가게 리뷰만 사용") # 5단계: Vector DB 구축 logger.info("5단계: Vector DB 구축 중...") try: # 대상 가게 정보를 딕셔너리로 변환 target_store_info_dict = { 'id': target_restaurant.id, 'place_name': target_restaurant.place_name, 'category_name': target_restaurant.category_name, 'address_name': target_restaurant.address_name, 'phone': target_restaurant.phone, 'place_url': target_restaurant.place_url, 'x': target_restaurant.x, 'y': target_restaurant.y } # Vector DB에 저장 vector_result = await vector_service.build_vector_store( target_store_info_dict, review_results, food_category, request.region ) if not vector_result.get('success', False): raise Exception(f"Vector DB 저장 실패: {vector_result.get('error', 'Unknown error')}") logger.info("✅ Vector DB 구축 완료") except Exception as e: logger.error(f"❌ Vector DB 구축 실패: {e}") raise HTTPException( status_code=500, detail=f"Vector DB 구축 중 오류가 발생했습니다: {str(e)}" ) # 최종 검증: 본인 가게가 첫 번째에 있는지 확인 if not review_results or review_results[0][0] != target_restaurant.id: logger.error("❌ 본인 가게가 첫 번째에 없음") raise HTTPException( status_code=500, detail="본인 가게 리뷰 처리 순서 오류가 발생했습니다." ) # 성공 응답 total_reviews = sum(len(reviews) for _, _, reviews in review_results) execution_time = (datetime.now() - start_time).total_seconds() return FindReviewsResponse( success=True, message=f"✅ 본인 가게 리뷰 포함 보장 완료! (총 {len(review_results)}개 가게, {total_reviews}개 리뷰)", target_store=RestaurantInfo( id=target_restaurant.id, place_name=target_restaurant.place_name, category_name=target_restaurant.category_name, address_name=target_restaurant.address_name, phone=target_restaurant.phone, place_url=target_restaurant.place_url, x=target_restaurant.x, y=target_restaurant.y ), total_stores=len(review_results), total_reviews=total_reviews, food_category=food_category, region=request.region ) except HTTPException: raise except Exception as e: execution_time = (datetime.now() - start_time).total_seconds() logger.error(f"❌ 전체 프로세스 실패: {e}") raise HTTPException( status_code=500, detail=f"서비스 처리 중 예상치 못한 오류가 발생했습니다: {str(e)}" ) def _is_duplicate_restaurant(restaurant1: RestaurantInfo, restaurant2: RestaurantInfo) -> bool: """ 두 음식점이 중복인지 확인 (개선된 로직) Args: restaurant1: 첫 번째 음식점 restaurant2: 두 번째 음식점 Returns: 중복 여부 """ # 1. ID 기준 확인 if restaurant1.id == restaurant2.id: return True # 2. place_url에서 추출한 store_id 기준 확인 store_id1 = _extract_store_id_from_place_url(restaurant1.place_url) store_id2 = _extract_store_id_from_place_url(restaurant2.place_url) if store_id1 and store_id2 and store_id1 == store_id2: return True # 3. restaurant.id와 place_url store_id 교차 확인 if restaurant1.id == store_id2 or restaurant2.id == store_id1: return True # 4. 이름 + 주소 기준 확인 (최후 방법) if (restaurant1.place_name == restaurant2.place_name and restaurant1.address_name == restaurant2.address_name): return True return False def _extract_store_id_from_place_url(place_url: str) -> Optional[str]: """ 카카오맵 URL에서 store_id를 추출합니다. Args: place_url: 카카오맵 장소 URL Returns: 추출된 store_id 또는 None """ try: if not place_url: return None import re # URL 패턴: https://place.map.kakao.com/123456789 pattern = r'/(\d+)(?:\?|$|#)' match = re.search(pattern, place_url) if match: return match.group(1) else: return None except Exception: return None @app.post( "/action-recommendation", response_model=ActionRecommendationResponse, summary="액션 추천 요청", description="점주가 Claude AI에게 액션 추천을 요청합니다." ) async def action_recommendation( request: ActionRecommendationRequest, claude_service: ClaudeService = Depends(get_claude_service), vector_service: VectorService = Depends(get_vector_service) ): """🧠 Claude AI 액션 추천 API""" try: logger.info(f"액션 추천 요청: store_id={request.store_id}") start_time = datetime.now() # 1단계: Vector DB에서 컨텍스트 조회 try: db_status = vector_service.get_db_status() if db_status.get('total_documents', 0) == 0: raise HTTPException( status_code=404, detail={ "success": False, "error": "NO_VECTOR_DATA", "message": "Vector DB에 데이터가 없습니다. 먼저 /build-vector API를 호출하여 데이터를 구축해주세요.", "timestamp": datetime.now().isoformat() } ) # Vector DB에서 유사한 케이스 검색 context_data = vector_service.search_similar_cases(request.store_id, request.context) except HTTPException: raise except Exception as e: logger.error(f"Vector DB 조회 실패: {e}") # Vector DB 조회 실패해도 일반적인 추천은 제공 context_data = None # 2단계: Claude AI 호출 (프롬프트 구성부터 파싱까지 모두 포함) try: # 컨텍스트 구성 full_context = f"가게 ID: {request.store_id}\n점주 요청: {request.context}" additional_context = context_data if context_data else None # Claude AI 액션 추천 생성 (완전한 처리) claude_response, parsed_response = await claude_service.generate_action_recommendations( context=full_context, additional_context=additional_context ) if not claude_response: raise Exception("Claude AI로부터 응답을 받지 못했습니다") logger.info(f"Claude 응답 길이: {len(claude_response)} 문자") json_parse_success = parsed_response is not None except Exception as e: logger.error(f"Claude AI 호출 실패: {e}") raise HTTPException( status_code=500, detail=f"AI 추천 생성 중 오류: {str(e)}" ) # 3단계: 응답 구성 claude_execution_time = (datetime.now() - start_time).total_seconds() # 가게 정보 추출 (Vector DB에서) store_name = request.store_id # 기본값 food_category = "기타" # 기본값 try: store_context = vector_service.get_store_context(request.store_id) if store_context: store_name = store_context.get('store_name', request.store_id) food_category = store_context.get('food_category', '기타') except Exception as e: logger.warning(f"가게 정보 추출 실패: {e}") response = ActionRecommendationResponse( success=True, message=f"액션 추천이 완료되었습니다. (실행시간: {claude_execution_time:.1f}초, JSON 파싱: {'성공' if json_parse_success else '실패'})", claude_input=full_context + (f"\n--- 동종 업체 분석 데이터 ---\n{additional_context}" if additional_context else ""), claude_response=claude_response, parsed_response=parsed_response, store_name=store_name, food_category=food_category, similar_stores_count=len(context_data.split("---")) if context_data else 0, execution_time=claude_execution_time, json_parse_success=json_parse_success ) logger.info(f"✅ 액션 추천 완료: Claude 응답 {len(claude_response) if claude_response else 0} 문자, JSON 파싱 {'성공' if json_parse_success else '실패'}, {claude_execution_time:.1f}초 소요") return response except HTTPException: raise except Exception as e: logger.error(f"❌ 액션 추천 요청 실패: {str(e)}") raise HTTPException( status_code=500, detail={ "success": False, "error": "RECOMMENDATION_FAILED", "message": f"액션 추천 중 오류가 발생했습니다: {str(e)}", "timestamp": datetime.now().isoformat() } ) @app.get( "/vector-status", response_model=VectorDBStatusResponse, summary="Vector DB 상태 조회", description="Vector DB의 현재 상태를 조회합니다." ) async def get_vector_db_status(vector_service: VectorService = Depends(get_vector_service)): """Vector DB 상태 조회 API""" try: status_info = vector_service.get_db_status() status = VectorDBStatus( collection_name=status_info['collection_name'], total_documents=status_info['total_documents'], total_stores=status_info['total_stores'], db_path=status_info['db_path'], last_updated=datetime.now().isoformat() ) return VectorDBStatusResponse( success=True, status=status, message="Vector DB 상태 조회가 완료되었습니다." ) except Exception as e: logger.error(f"Vector DB 상태 조회 실패: {e}") raise HTTPException( status_code=500, detail={ "success": False, "error": "STATUS_CHECK_FAILED", "message": f"Vector DB 상태 조회 중 오류가 발생했습니다: {str(e)}", "timestamp": datetime.now().isoformat() } ) @app.get("/health", summary="헬스 체크", description="API 서버 및 외부 서비스 상태를 확인합니다.") async def health_check(): """🏥 헬스체크 API""" health_result = { "status": "healthy", "timestamp": datetime.now().isoformat(), "services": {}, "app_info": { "name": settings.APP_TITLE, "version": settings.APP_VERSION, "startup_completed": app_state["startup_completed"] } } # 서비스별 헬스체크 services_to_check = [ ("restaurant_service", "restaurant_api"), ("review_service", "review_api"), ("claude_service", "claude_ai"), ("vector_service", "vector_db") ] healthy_count = 0 total_checks = len(services_to_check) for service_key, health_key in services_to_check: try: service = app_state.get(service_key) if service is None: health_result["services"][health_key] = "not_initialized" continue # 서비스별 헬스체크 메서드 호출 if hasattr(service, 'health_check'): status = await service.health_check() else: status = True # 헬스체크 메서드가 없으면 초기화됐다고 가정 # Vector DB의 경우 상세 정보 추가 if health_key == "vector_db" and status: try: db_status = service.get_db_status() health_result["vector_db_info"] = { "total_documents": db_status.get('total_documents', 0), "total_stores": db_status.get('total_stores', 0), "db_path": db_status.get('db_path', '') } except: pass health_result["services"][health_key] = "healthy" if status else "unhealthy" if status: healthy_count += 1 except Exception as e: logger.warning(f"헬스체크 실패 - {service_key}: {e}") health_result["services"][health_key] = f"error: {str(e)}" # 전체 상태 결정 if healthy_count == total_checks: health_result["status"] = "healthy" elif healthy_count > 0: health_result["status"] = "degraded" else: health_result["status"] = "unhealthy" # 요약 정보 health_result["summary"] = { "healthy_services": healthy_count, "total_services": total_checks, "health_percentage": round((healthy_count / total_checks) * 100, 1) } # 초기화 에러가 있으면 포함 if app_state["initialization_errors"]: health_result["initialization_errors"] = app_state["initialization_errors"] # 환경 정보 health_result["environment"] = { "python_version": sys.version.split()[0], "fastapi_version": fastapi.__version__, "is_k8s": hasattr(settings, 'IS_K8S_ENV') and settings.IS_K8S_ENV, "claude_model": settings.CLAUDE_MODEL } # HTTP 상태 코드 결정 if health_result["status"] == "healthy": return health_result elif health_result["status"] == "degraded": return JSONResponse(status_code=200, content=health_result) # 부분 장애는 200 else: return JSONResponse(status_code=503, content=health_result) # 전체 장애는 503 # 🔧 전역 예외 처리 @app.exception_handler(Exception) async def global_exception_handler(request, exc): """전역 예외 처리""" logger.error(f"Unhandled exception: {exc}") return JSONResponse( status_code=500, content={ "error": "Internal server error", "detail": str(exc) if settings.LOG_LEVEL.lower() == "debug" else "An unexpected error occurred", "timestamp": datetime.now().isoformat(), "path": str(request.url) } ) if __name__ == "__main__": import uvicorn print("🍽️ " + "="*60) print(f" {settings.APP_TITLE} 서버 시작") print("="*64) print(f"📊 구성 정보:") print(f" - Python 버전: {sys.version.split()[0]}") print(f" - FastAPI 버전: {fastapi.__version__}") print(f" - Vector DB Path: {settings.VECTOR_DB_PATH}") print(f" - Claude Model: {settings.CLAUDE_MODEL}") print(f" - 환경: {'Kubernetes' if hasattr(settings, 'IS_K8S_ENV') and settings.IS_K8S_ENV else 'Local'}") print() print(f"📚 문서:") print(f" - Swagger UI: http://{settings.HOST}:{settings.PORT}/docs") print(f" - ReDoc: http://{settings.HOST}:{settings.PORT}/redoc") print(f" - 메인 페이지: http://{settings.HOST}:{settings.PORT}/") print() try: uvicorn.run( "app.main:app", # 🔧 문자열로 지정 (리로드 지원) host=settings.HOST, port=settings.PORT, log_level=settings.LOG_LEVEL.lower(), reload=False, # 프로덕션에서는 False access_log=True, loop="uvloop" if sys.platform != "win32" else "asyncio" ) except KeyboardInterrupt: print("\n🛑 서버가 사용자에 의해 중단되었습니다.") except Exception as e: print(f"\n❌ 서버 시작 실패: {e}") import traceback traceback.print_exc() sys.exit(1)