This commit is contained in:
@@ -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)}")
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user