# vector/app/main.py import os import sys import logging from contextlib import asynccontextmanager from datetime import datetime from typing import Optional # 현재 디렉토리를 Python 경로에 추가 sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) # ============================================================================= # .env 파일 로딩 (다른 import보다 먼저) # ============================================================================= from dotenv import load_dotenv # .env 파일에서 환경변수 로드 load_dotenv() 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_DESCRIPTION}
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)