feat : initial commit
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
View File
+98
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
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
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
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()