mirror of
https://github.com/hwanny1128/HGZero.git
synced 2026-06-13 03:39:10 +00:00
feat: rag 서비스 Event Hub 연동 및 연관 회의록 API 추가
This commit is contained in:
Binary file not shown.
+138
-1
@@ -22,7 +22,9 @@ from ..models.document import (
|
||||
)
|
||||
from ..models.minutes import (
|
||||
MinutesSearchRequest,
|
||||
MinutesSearchResult
|
||||
MinutesSearchResult,
|
||||
RelatedMinutesRequest,
|
||||
RelatedMinutesResponse
|
||||
)
|
||||
from ..db.postgres_vector import PostgresVectorDB
|
||||
from ..db.azure_search import AzureAISearchDB
|
||||
@@ -31,6 +33,7 @@ from ..services.claude_service import ClaudeService
|
||||
from ..utils.config import load_config, get_database_url
|
||||
from ..utils.embedding import EmbeddingGenerator
|
||||
from ..utils.text_processor import extract_nouns_as_query
|
||||
from ..utils.redis_cache import RedisCache
|
||||
|
||||
# 로깅 설정
|
||||
logging.basicConfig(
|
||||
@@ -62,6 +65,7 @@ _doc_db = None
|
||||
_rag_minutes_db = None
|
||||
_embedding_gen = None
|
||||
_claude_service = None
|
||||
_redis_cache = None
|
||||
|
||||
|
||||
def get_config():
|
||||
@@ -139,6 +143,22 @@ def get_claude_service():
|
||||
return _claude_service
|
||||
|
||||
|
||||
def get_redis_cache():
|
||||
"""Redis 캐시"""
|
||||
global _redis_cache
|
||||
if _redis_cache is None:
|
||||
config = get_config()
|
||||
redis_config = config["redis"]
|
||||
_redis_cache = RedisCache(
|
||||
host=redis_config["host"],
|
||||
port=redis_config["port"],
|
||||
db=redis_config["db"],
|
||||
password=redis_config.get("password"),
|
||||
decode_responses=redis_config.get("decode_responses", True)
|
||||
)
|
||||
return _redis_cache
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# 용어집 API
|
||||
# ============================================================================
|
||||
@@ -501,6 +521,123 @@ async def get_minutes_stats(rag_minutes_db: RagMinutesDB = Depends(get_rag_minut
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@app.post("/api/minutes/related", response_model=List[RelatedMinutesResponse])
|
||||
async def get_related_minutes(
|
||||
request: RelatedMinutesRequest,
|
||||
rag_minutes_db: RagMinutesDB = Depends(get_rag_minutes_db),
|
||||
embedding_gen: EmbeddingGenerator = Depends(get_embedding_gen),
|
||||
redis_cache: RedisCache = Depends(get_redis_cache)
|
||||
):
|
||||
"""
|
||||
연관 회의록 조회 (Option A: DB 조회 후 벡터 검색 + Redis 캐싱)
|
||||
|
||||
Args:
|
||||
request: 연관 회의록 조회 요청
|
||||
- minute_id: 기준 회의록 ID
|
||||
- meeting_title: 회의 제목 (미사용)
|
||||
- summary: 회의록 요약 (미사용)
|
||||
- top_k: 반환할 최대 결과 수
|
||||
- similarity_threshold: 최소 유사도 임계값
|
||||
|
||||
Returns:
|
||||
연관 회의록 리스트
|
||||
|
||||
Process:
|
||||
1. Redis 캐시에서 연관 회의록 결과 조회
|
||||
2. 캐시 MISS 시:
|
||||
a. minute_id로 rag_minutes 테이블에서 회의록 조회 (캐싱)
|
||||
b. full_content를 벡터 임베딩으로 변환
|
||||
c. 벡터 DB에서 유사도 검색 (자기 자신 제외)
|
||||
d. 결과를 Redis에 캐싱
|
||||
3. 연관 회의록 목록 반환
|
||||
"""
|
||||
try:
|
||||
config = get_config()
|
||||
cache_config = config.get("rag_minutes", {}).get("cache", {})
|
||||
cache_prefix = cache_config.get("prefix", "minutes:")
|
||||
minutes_ttl = cache_config.get("ttl", 1800)
|
||||
related_ttl = cache_config.get("related_ttl", 3600)
|
||||
|
||||
logger.info(f"연관 회의록 조회 시작: minute_id={request.minute_id}")
|
||||
|
||||
# 1. 캐시 키 생성
|
||||
related_cache_key = (
|
||||
f"{cache_prefix}related:{request.minute_id}:"
|
||||
f"{request.top_k}:{request.similarity_threshold}"
|
||||
)
|
||||
|
||||
# 2. 캐시 조회
|
||||
cached_results = redis_cache.get(related_cache_key)
|
||||
if cached_results:
|
||||
logger.info(f"연관 회의록 캐시 HIT: {related_cache_key}")
|
||||
return [
|
||||
RelatedMinutesResponse(**result)
|
||||
for result in cached_results
|
||||
]
|
||||
|
||||
# 3. 캐시 MISS - DB 조회
|
||||
logger.info(f"연관 회의록 캐시 MISS: {related_cache_key}")
|
||||
|
||||
# 3-1. 회의록 조회 (캐싱)
|
||||
minutes_cache_key = f"{cache_prefix}{request.minute_id}"
|
||||
base_minutes = redis_cache.get(minutes_cache_key)
|
||||
|
||||
if base_minutes:
|
||||
logger.info(f"회의록 캐시 HIT: {minutes_cache_key}")
|
||||
# RagMinutes 객체로 변환
|
||||
from ..models.minutes import RagMinutes
|
||||
base_minutes = RagMinutes(**base_minutes)
|
||||
else:
|
||||
logger.info(f"회의록 캐시 MISS: {minutes_cache_key}")
|
||||
base_minutes = rag_minutes_db.get_minutes_by_id(request.minute_id)
|
||||
if not base_minutes:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"회의록을 찾을 수 없습니다: {request.minute_id}"
|
||||
)
|
||||
# 캐시 저장
|
||||
redis_cache.set(minutes_cache_key, base_minutes.dict(), minutes_ttl)
|
||||
|
||||
logger.info(f"기준 회의록 조회 완료: {base_minutes.title}")
|
||||
|
||||
# 3-2. full_content를 벡터 임베딩으로 변환
|
||||
query_embedding = embedding_gen.generate_embedding(base_minutes.full_content)
|
||||
logger.info(f"임베딩 생성 완료: {len(query_embedding)}차원")
|
||||
|
||||
# 3-3. 벡터 유사도 검색 (자기 자신 제외)
|
||||
results = rag_minutes_db.search_by_vector(
|
||||
query_embedding=query_embedding,
|
||||
top_k=request.top_k,
|
||||
similarity_threshold=request.similarity_threshold,
|
||||
exclude_minutes_id=request.minute_id
|
||||
)
|
||||
|
||||
# 4. 응답 형식으로 변환
|
||||
related_minutes = [
|
||||
RelatedMinutesResponse(
|
||||
minutes=result["minutes"],
|
||||
similarity_score=result["similarity_score"]
|
||||
)
|
||||
for result in results
|
||||
]
|
||||
|
||||
# 5. 결과 캐싱
|
||||
redis_cache.set(
|
||||
related_cache_key,
|
||||
[r.dict() for r in related_minutes],
|
||||
related_ttl
|
||||
)
|
||||
|
||||
logger.info(f"연관 회의록 조회 완료: {len(related_minutes)}개 결과")
|
||||
return related_minutes
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"연관 회의록 조회 실패: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
uvicorn.run(app, host="0.0.0.0", port=8000)
|
||||
|
||||
Binary file not shown.
@@ -92,6 +92,8 @@ class RagMinutesDB:
|
||||
if field in minutes_dict and minutes_dict[field]:
|
||||
if isinstance(minutes_dict[field], datetime):
|
||||
minutes_dict[field] = minutes_dict[field].isoformat()
|
||||
|
||||
minutes_dict.pop("embedding")
|
||||
|
||||
return RagMinutes(**minutes_dict)
|
||||
|
||||
@@ -189,7 +191,8 @@ class RagMinutesDB:
|
||||
self,
|
||||
query_embedding: List[float],
|
||||
top_k: int = 5,
|
||||
similarity_threshold: float = 0.7
|
||||
similarity_threshold: float = 0.7,
|
||||
exclude_minutes_id: Optional[str] = None
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
벡터 유사도 검색
|
||||
@@ -198,27 +201,34 @@ class RagMinutesDB:
|
||||
query_embedding: 쿼리 임베딩 벡터
|
||||
top_k: 반환할 최대 결과 수
|
||||
similarity_threshold: 최소 유사도 임계값
|
||||
exclude_minutes_id: 제외할 회의록 ID (연관 회의록 검색 시 자기 자신 제외)
|
||||
|
||||
Returns:
|
||||
검색 결과 리스트
|
||||
"""
|
||||
with self.get_connection() as conn:
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||
cur.execute("""
|
||||
# 제외 조건 추가
|
||||
exclude_condition = ""
|
||||
params = [query_embedding, query_embedding, similarity_threshold, query_embedding, top_k]
|
||||
|
||||
if exclude_minutes_id:
|
||||
exclude_condition = "AND minutes_id != %s"
|
||||
# 파라미터 순서: 처음 4개는 embedding 검색용, 5번째는 exclude용, 6번째는 limit용
|
||||
params = [query_embedding, query_embedding, similarity_threshold, exclude_minutes_id, query_embedding, top_k]
|
||||
|
||||
query = f"""
|
||||
SELECT *,
|
||||
1 - (embedding <=> %s::vector) as similarity_score
|
||||
FROM rag_minutes
|
||||
WHERE embedding IS NOT NULL
|
||||
AND 1 - (embedding <=> %s::vector) >= %s
|
||||
{exclude_condition}
|
||||
ORDER BY embedding <=> %s::vector
|
||||
LIMIT %s
|
||||
""", (
|
||||
query_embedding,
|
||||
query_embedding,
|
||||
similarity_threshold,
|
||||
query_embedding,
|
||||
top_k
|
||||
))
|
||||
"""
|
||||
|
||||
cur.execute(query, params)
|
||||
|
||||
results = []
|
||||
for row in cur.fetchall():
|
||||
@@ -229,7 +239,7 @@ class RagMinutesDB:
|
||||
"similarity_score": float(similarity_score)
|
||||
})
|
||||
|
||||
logger.info(f"벡터 검색 완료: {len(results)}개 결과")
|
||||
logger.info(f"벡터 검색 완료: {len(results)}개 결과 (exclude: {exclude_minutes_id})")
|
||||
return results
|
||||
|
||||
def search_by_keyword(
|
||||
|
||||
Binary file not shown.
@@ -106,3 +106,41 @@ class MinutesSearchResult(BaseModel):
|
||||
"similarity_score": 0.92
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class RelatedMinutesRequest(BaseModel):
|
||||
"""연관 회의록 조회 요청"""
|
||||
minute_id: str = Field(..., description="기준 회의록 ID")
|
||||
meeting_title: str = Field(..., description="회의 제목")
|
||||
summary: str = Field(..., description="회의록 요약")
|
||||
top_k: int = Field(5, ge=1, le=20, description="반환할 최대 결과 수")
|
||||
similarity_threshold: float = Field(0.7, ge=0.0, le=1.0, description="최소 유사도 임계값")
|
||||
|
||||
class Config:
|
||||
json_schema_extra = {
|
||||
"example": {
|
||||
"minute_id": "MIN-2025-001",
|
||||
"meeting_title": "2025 Q1 마케팅 전략 회의",
|
||||
"summary": "2025년 1분기 마케팅 전략 수립을 위한 회의",
|
||||
"top_k": 5,
|
||||
"similarity_threshold": 0.7
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class RelatedMinutesResponse(BaseModel):
|
||||
"""연관 회의록 조회 응답"""
|
||||
minutes: RagMinutes
|
||||
similarity_score: float = Field(..., ge=0.0, le=1.0, description="유사도 점수")
|
||||
|
||||
class Config:
|
||||
json_schema_extra = {
|
||||
"example": {
|
||||
"minutes": {
|
||||
"meeting_id": "MTG-2025-002",
|
||||
"title": "2024 Q4 마케팅 성과 분석",
|
||||
"minutes_id": "MIN-2024-050"
|
||||
},
|
||||
"similarity_score": 0.85
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
@@ -1,6 +1,6 @@
|
||||
"""
|
||||
Azure Event Hub Consumer 서비스
|
||||
회의록 확정 이벤트를 consume하여 RAG 저장소에 저장
|
||||
회의록 확정 이벤트 및 세그먼트 생성 이벤트를 consume
|
||||
"""
|
||||
import asyncio
|
||||
import json
|
||||
@@ -13,7 +13,9 @@ from azure.eventhub.extensions.checkpointstoreblobaio import BlobCheckpointStore
|
||||
|
||||
from ..models.minutes import RagMinutes, MinutesSection
|
||||
from ..db.rag_minutes_db import RagMinutesDB
|
||||
from ..db.postgres_vector import PostgresVectorDB
|
||||
from ..utils.embedding import EmbeddingGenerator
|
||||
from ..utils.text_processor import extract_nouns_as_query
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -29,7 +31,9 @@ class EventHubConsumer:
|
||||
storage_connection_string: str,
|
||||
storage_container_name: str,
|
||||
rag_minutes_db: RagMinutesDB,
|
||||
embedding_gen: EmbeddingGenerator
|
||||
embedding_gen: EmbeddingGenerator,
|
||||
term_db: Optional[PostgresVectorDB] = None,
|
||||
config: Optional[Dict[str, Any]] = None
|
||||
):
|
||||
"""
|
||||
초기화
|
||||
@@ -42,6 +46,8 @@ class EventHubConsumer:
|
||||
storage_container_name: Checkpoint 저장 컨테이너 이름
|
||||
rag_minutes_db: RAG Minutes 데이터베이스
|
||||
embedding_gen: Embedding 생성기
|
||||
term_db: 용어집 데이터베이스 (선택)
|
||||
config: 설정 딕셔너리 (선택)
|
||||
"""
|
||||
self.connection_string = connection_string
|
||||
self.eventhub_name = eventhub_name
|
||||
@@ -50,6 +56,8 @@ class EventHubConsumer:
|
||||
self.storage_container_name = storage_container_name
|
||||
self.rag_minutes_db = rag_minutes_db
|
||||
self.embedding_gen = embedding_gen
|
||||
self.term_db = term_db
|
||||
self.config = config or {}
|
||||
self.client: Optional[EventHubConsumerClient] = None
|
||||
self.is_running = False
|
||||
|
||||
@@ -106,13 +114,18 @@ class EventHubConsumer:
|
||||
event_body = event.body_as_str()
|
||||
event_data = json.loads(event_body)
|
||||
|
||||
logger.info(f"이벤트 수신: {event_data.get('eventType', 'unknown')}")
|
||||
logger.info(f"이벤트 수신: {event_data.get('data', 'unknown')}")
|
||||
event_type = event_data.get('eventType', 'unknown')
|
||||
logger.info(f"이벤트 수신: {event_type}")
|
||||
|
||||
# 회의록 확정 이벤트 처리
|
||||
if event_data.get("eventType") == "MINUTES_FINALIZED":
|
||||
# 이벤트 타입별 처리
|
||||
if event_type == "MINUTES_FINALIZED":
|
||||
# 회의록 확정 이벤트
|
||||
await self._process_minutes_event(event_data)
|
||||
|
||||
elif event_type == "SegmentCreated":
|
||||
# 세그먼트 생성 이벤트 - 용어검색 실행
|
||||
await self._process_segment_event(event_data)
|
||||
|
||||
# Checkpoint 업데이트
|
||||
await partition_context.update_checkpoint(event)
|
||||
|
||||
@@ -131,6 +144,110 @@ class EventHubConsumer:
|
||||
"""
|
||||
logger.error(f"Event Hub 에러 (Partition {partition_context.partition_id}): {str(error)}")
|
||||
|
||||
async def _process_segment_event(self, event_data: Dict[str, Any]):
|
||||
"""
|
||||
세그먼트 생성 이벤트 처리 - 용어검색 실행
|
||||
|
||||
Args:
|
||||
event_data: 이벤트 데이터
|
||||
"""
|
||||
try:
|
||||
# 용어집 DB가 없으면 스킵
|
||||
if not self.term_db:
|
||||
logger.debug("용어집 DB가 설정되지 않아 용어검색을 스킵합니다")
|
||||
return
|
||||
|
||||
# 세그먼트 데이터 추출
|
||||
segment_id = event_data.get("segmentId")
|
||||
text = event_data.get("text", "")
|
||||
meeting_id = event_data.get("meetingId")
|
||||
|
||||
if not text:
|
||||
logger.warning(f"세그먼트 {segment_id}에 텍스트가 없습니다")
|
||||
return
|
||||
|
||||
logger.info(f"세그먼트 용어검색 시작: {segment_id} (회의: {meeting_id})")
|
||||
logger.info(f"텍스트: {text[:100]}...")
|
||||
|
||||
# 1. 명사 추출하여 검색 쿼리 생성
|
||||
search_query = extract_nouns_as_query(text)
|
||||
logger.info(f"검색 쿼리 변환: '{text[:30]}...' → '{search_query}'")
|
||||
|
||||
# 2. 용어검색 설정
|
||||
config = self.config.get("term_glossary", {})
|
||||
search_config = config.get("search", {})
|
||||
|
||||
top_k = search_config.get("top_k", 5)
|
||||
confidence_threshold = search_config.get("confidence_threshold", 0.7)
|
||||
keyword_weight = search_config.get("keyword_weight", 0.4)
|
||||
vector_weight = search_config.get("vector_weight", 0.6)
|
||||
|
||||
# 3. 키워드 검색
|
||||
keyword_results = self.term_db.search_by_keyword(
|
||||
query=search_query,
|
||||
top_k=top_k,
|
||||
confidence_threshold=confidence_threshold
|
||||
)
|
||||
|
||||
# 4. 벡터 검색
|
||||
query_embedding = self.embedding_gen.generate_embedding(search_query)
|
||||
vector_results = self.term_db.search_by_vector(
|
||||
query_embedding=query_embedding,
|
||||
top_k=top_k,
|
||||
confidence_threshold=confidence_threshold
|
||||
)
|
||||
|
||||
# 5. 하이브리드 검색 결과 통합 (RRF)
|
||||
results = []
|
||||
seen_ids = set()
|
||||
|
||||
# 키워드 결과 가중치 적용
|
||||
for result in keyword_results:
|
||||
term_id = result["term"].term_id
|
||||
if term_id not in seen_ids:
|
||||
result["relevance_score"] *= keyword_weight
|
||||
result["match_type"] = "hybrid"
|
||||
results.append(result)
|
||||
seen_ids.add(term_id)
|
||||
|
||||
# 벡터 결과 가중치 적용
|
||||
for result in vector_results:
|
||||
term_id = result["term"].term_id
|
||||
if term_id not in seen_ids:
|
||||
result["relevance_score"] *= vector_weight
|
||||
result["match_type"] = "hybrid"
|
||||
results.append(result)
|
||||
seen_ids.add(term_id)
|
||||
else:
|
||||
# 이미 있는 경우 점수 합산
|
||||
for r in results:
|
||||
if r["term"].term_id == term_id:
|
||||
r["relevance_score"] += result["relevance_score"] * vector_weight
|
||||
break
|
||||
|
||||
# 점수 기준 재정렬
|
||||
results.sort(key=lambda x: x["relevance_score"], reverse=True)
|
||||
results = results[:top_k]
|
||||
|
||||
# 6. 검색 결과 로깅
|
||||
if results:
|
||||
logger.info(f"세그먼트 {segment_id} 용어검색 완료: {len(results)}개 발견")
|
||||
for idx, result in enumerate(results, 1):
|
||||
term = result["term"]
|
||||
score = result["relevance_score"]
|
||||
logger.info(
|
||||
f" [{idx}] {term.term_name} "
|
||||
f"(카테고리: {term.category}, 점수: {score:.3f})"
|
||||
)
|
||||
else:
|
||||
logger.info(f"세그먼트 {segment_id}에서 매칭되는 용어를 찾지 못했습니다")
|
||||
|
||||
# 7. 선택적: 검색 결과를 별도 테이블에 저장하거나 Event Hub로 발행
|
||||
# TODO: 필요시 검색 결과를 저장하거나 downstream 서비스로 전달
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"세그먼트 이벤트 처리 실패: {str(e)}", exc_info=True)
|
||||
|
||||
def _convert_datetime_array_to_string(self, value: Union[str, List, None]) -> Optional[str]:
|
||||
"""
|
||||
Java LocalDateTime 배열을 ISO 8601 문자열로 변환
|
||||
@@ -302,7 +419,8 @@ class EventHubConsumer:
|
||||
async def start_consumer(
|
||||
config: Dict[str, Any],
|
||||
rag_minutes_db: RagMinutesDB,
|
||||
embedding_gen: EmbeddingGenerator
|
||||
embedding_gen: EmbeddingGenerator,
|
||||
term_db: Optional[PostgresVectorDB] = None
|
||||
):
|
||||
"""
|
||||
Event Hub Consumer 시작 (비동기)
|
||||
@@ -311,6 +429,7 @@ async def start_consumer(
|
||||
config: 설정 딕셔너리
|
||||
rag_minutes_db: RAG Minutes 데이터베이스
|
||||
embedding_gen: Embedding 생성기
|
||||
term_db: 용어집 데이터베이스 (선택)
|
||||
"""
|
||||
eventhub_config = config["eventhub"]
|
||||
|
||||
@@ -321,7 +440,9 @@ async def start_consumer(
|
||||
storage_connection_string=eventhub_config["storage"]["connection_string"],
|
||||
storage_container_name=eventhub_config["storage"]["container_name"],
|
||||
rag_minutes_db=rag_minutes_db,
|
||||
embedding_gen=embedding_gen
|
||||
embedding_gen=embedding_gen,
|
||||
term_db=term_db,
|
||||
config=config
|
||||
)
|
||||
|
||||
try:
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,206 @@
|
||||
"""
|
||||
Redis 캐싱 유틸리티
|
||||
"""
|
||||
import redis
|
||||
import json
|
||||
import logging
|
||||
from typing import Optional, Any
|
||||
from functools import wraps
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RedisCache:
|
||||
"""Redis 캐싱 클래스"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
host: str = "localhost",
|
||||
port: int = 6379,
|
||||
db: int = 0,
|
||||
password: Optional[str] = None,
|
||||
decode_responses: bool = True
|
||||
):
|
||||
"""
|
||||
초기화
|
||||
|
||||
Args:
|
||||
host: Redis 호스트
|
||||
port: Redis 포트
|
||||
db: Redis DB 번호
|
||||
password: Redis 비밀번호
|
||||
decode_responses: 응답 디코딩 여부
|
||||
"""
|
||||
try:
|
||||
self.client = redis.Redis(
|
||||
host=host,
|
||||
port=port,
|
||||
db=db,
|
||||
password=password,
|
||||
decode_responses=decode_responses
|
||||
)
|
||||
# 연결 테스트
|
||||
self.client.ping()
|
||||
logger.info(f"Redis 연결 성공: {host}:{port}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Redis 연결 실패: {str(e)} - 캐싱 비활성화")
|
||||
self.client = None
|
||||
|
||||
def get(self, key: str) -> Optional[Any]:
|
||||
"""
|
||||
캐시에서 값 조회
|
||||
|
||||
Args:
|
||||
key: 캐시 키
|
||||
|
||||
Returns:
|
||||
캐시된 값 또는 None
|
||||
"""
|
||||
if not self.client:
|
||||
return None
|
||||
|
||||
try:
|
||||
value = self.client.get(key)
|
||||
if value:
|
||||
logger.debug(f"캐시 HIT: {key}")
|
||||
return json.loads(value)
|
||||
logger.debug(f"캐시 MISS: {key}")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"캐시 조회 실패 ({key}): {str(e)}")
|
||||
return None
|
||||
|
||||
def set(self, key: str, value: Any, ttl: int = 3600) -> bool:
|
||||
"""
|
||||
캐시에 값 저장
|
||||
|
||||
Args:
|
||||
key: 캐시 키
|
||||
value: 저장할 값
|
||||
ttl: 만료 시간 (초)
|
||||
|
||||
Returns:
|
||||
성공 여부
|
||||
"""
|
||||
if not self.client:
|
||||
return False
|
||||
|
||||
try:
|
||||
serialized = json.dumps(value, ensure_ascii=False)
|
||||
self.client.setex(key, ttl, serialized)
|
||||
logger.debug(f"캐시 저장: {key} (TTL: {ttl}s)")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"캐시 저장 실패 ({key}): {str(e)}")
|
||||
return False
|
||||
|
||||
def delete(self, key: str) -> bool:
|
||||
"""
|
||||
캐시 삭제
|
||||
|
||||
Args:
|
||||
key: 캐시 키
|
||||
|
||||
Returns:
|
||||
성공 여부
|
||||
"""
|
||||
if not self.client:
|
||||
return False
|
||||
|
||||
try:
|
||||
self.client.delete(key)
|
||||
logger.debug(f"캐시 삭제: {key}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"캐시 삭제 실패 ({key}): {str(e)}")
|
||||
return False
|
||||
|
||||
def delete_pattern(self, pattern: str) -> int:
|
||||
"""
|
||||
패턴 매칭으로 여러 캐시 삭제
|
||||
|
||||
Args:
|
||||
pattern: 캐시 키 패턴 (예: "minutes:*")
|
||||
|
||||
Returns:
|
||||
삭제된 키 개수
|
||||
"""
|
||||
if not self.client:
|
||||
return 0
|
||||
|
||||
try:
|
||||
keys = self.client.keys(pattern)
|
||||
if keys:
|
||||
count = self.client.delete(*keys)
|
||||
logger.info(f"캐시 일괄 삭제: {count}개 키 ({pattern})")
|
||||
return count
|
||||
return 0
|
||||
except Exception as e:
|
||||
logger.error(f"캐시 패턴 삭제 실패 ({pattern}): {str(e)}")
|
||||
return 0
|
||||
|
||||
def exists(self, key: str) -> bool:
|
||||
"""
|
||||
캐시 존재 여부 확인
|
||||
|
||||
Args:
|
||||
key: 캐시 키
|
||||
|
||||
Returns:
|
||||
존재 여부
|
||||
"""
|
||||
if not self.client:
|
||||
return False
|
||||
|
||||
try:
|
||||
return self.client.exists(key) > 0
|
||||
except Exception as e:
|
||||
logger.error(f"캐시 존재 확인 실패 ({key}): {str(e)}")
|
||||
return False
|
||||
|
||||
|
||||
def cached(prefix: str, ttl: int = 3600, key_builder=None):
|
||||
"""
|
||||
함수 결과 캐싱 데코레이터
|
||||
|
||||
Args:
|
||||
prefix: 캐시 키 prefix
|
||||
ttl: 만료 시간 (초)
|
||||
key_builder: 캐시 키 생성 함수 (선택사항)
|
||||
|
||||
Example:
|
||||
@cached(prefix="minutes:", ttl=1800)
|
||||
def get_minutes_by_id(minutes_id: str):
|
||||
...
|
||||
"""
|
||||
def decorator(func):
|
||||
@wraps(func)
|
||||
def wrapper(self, *args, **kwargs):
|
||||
# Redis 캐시 인스턴스 확인
|
||||
cache = getattr(self, '_cache', None)
|
||||
if not cache or not cache.client:
|
||||
return func(self, *args, **kwargs)
|
||||
|
||||
# 캐시 키 생성
|
||||
if key_builder:
|
||||
cache_key = key_builder(*args, **kwargs)
|
||||
else:
|
||||
# 기본: 첫 번째 인자를 키로 사용
|
||||
cache_key = f"{prefix}{args[0] if args else ''}"
|
||||
|
||||
# 캐시 조회
|
||||
cached_value = cache.get(cache_key)
|
||||
if cached_value is not None:
|
||||
return cached_value
|
||||
|
||||
# 함수 실행
|
||||
result = func(self, *args, **kwargs)
|
||||
|
||||
# 결과 캐싱
|
||||
if result is not None:
|
||||
cache.set(cache_key, result, ttl)
|
||||
|
||||
return result
|
||||
|
||||
return wrapper
|
||||
return decorator
|
||||
Reference in New Issue
Block a user