feat: init rag service

This commit is contained in:
djeon
2025-10-29 05:54:08 +09:00
parent 44ae9c546f
commit 5d897cb845
54 changed files with 6425 additions and 0 deletions
View File
+210
View File
@@ -0,0 +1,210 @@
"""
Claude AI 연동 서비스
"""
from anthropic import Anthropic
from typing import Dict, Any, Optional
from tenacity import retry, stop_after_attempt, wait_exponential
import logging
logger = logging.getLogger(__name__)
class ClaudeService:
"""Claude AI 서비스"""
def __init__(
self,
api_key: str,
model: str = "claude-3-5-sonnet-20241022",
max_tokens: int = 1024,
temperature: float = 0.3
):
"""
초기화
Args:
api_key: Claude API 키
model: 모델명
max_tokens: 최대 토큰 수
temperature: 온도
"""
self.client = Anthropic(api_key=api_key)
self.model = model
self.max_tokens = max_tokens
self.temperature = temperature
@retry(
stop=stop_after_attempt(3),
wait=wait_exponential(multiplier=1, min=2, max=10)
)
def explain_term(
self,
term_name: str,
definition: str,
context: Optional[str],
meeting_context: Optional[str] = None,
related_docs: Optional[str] = None
) -> Dict[str, Any]:
"""
용어 설명 생성
Args:
term_name: 용어명
definition: 용어 정의
context: 회사 내 사용 맥락
meeting_context: 회의 맥락
related_docs: 관련 문서
Returns:
설명 결과
"""
# 시스템 프롬프트
system_prompt = (
"당신은 전문 용어를 회의 맥락에 맞춰 설명하는 AI 어시스턴트입니다. "
"2-3문장으로 간결하게 설명하세요."
)
# 사용자 프롬프트
user_prompt = f"용어: {term_name}\n\n"
user_prompt += f"정의: {definition}\n\n"
if context:
user_prompt += f"회사 내 사용 맥락: {context}\n\n"
if meeting_context:
user_prompt += f"회의 맥락: {meeting_context}\n\n"
if related_docs:
user_prompt += f"관련 문서:\n{related_docs}\n\n"
user_prompt += (
"위 정보를 바탕으로 이 용어를 2-3문장으로 간결하게 설명해주세요. "
"회의 맥락이 있다면 회의와 연관지어 설명하세요."
)
try:
response = self.client.messages.create(
model=self.model,
max_tokens=self.max_tokens,
temperature=self.temperature,
system=system_prompt,
messages=[
{"role": "user", "content": user_prompt}
]
)
explanation = response.content[0].text
tokens_used = response.usage.input_tokens + response.usage.output_tokens
return {
"explanation": explanation,
"generated_by": self.model,
"tokens_used": tokens_used,
"cached": False
}
except Exception as e:
logger.error(f"Claude API 호출 실패: {str(e)}")
# Fallback: 기본 설명 반환
fallback_explanation = f"{definition}"
if context:
fallback_explanation += f"\n\n{context}"
return {
"explanation": fallback_explanation,
"generated_by": "fallback",
"tokens_used": 0,
"cached": False,
"error": str(e)
}
@retry(
stop=stop_after_attempt(3),
wait=wait_exponential(multiplier=1, min=2, max=10)
)
def summarize_similar_content(
self,
current_meeting_title: str,
current_meeting_date: str,
current_meeting_agendas: str,
past_meeting_title: str,
past_meeting_date: str,
past_meeting_content: str
) -> Dict[str, Any]:
"""
관련 회의록 유사 내용 요약 생성
Args:
current_meeting_title: 현재 회의 제목
current_meeting_date: 현재 회의 날짜
current_meeting_agendas: 현재 회의 안건
past_meeting_title: 과거 회의 제목
past_meeting_date: 과거 회의 날짜
past_meeting_content: 과거 회의 내용
Returns:
요약 결과
"""
# 시스템 프롬프트
system_prompt = (
"당신은 회의록 분석 전문가입니다. "
"두 회의록을 비교하여 유사한 내용을 정확하게 추출하고 간결하게 요약합니다.\n\n"
"중요한 원칙:\n"
"1. 과거 회의록에서 실제로 다뤄진 내용만 포함하세요\n"
"2. 환각(Hallucination)을 절대 생성하지 마세요\n"
"3. 구체적인 날짜, 수치, 결정사항을 포함하세요\n"
"4. 정확히 3문장으로 요약하세요"
)
# 사용자 프롬프트
user_prompt = f"""아래 두 회의록을 비교하여 유사한 내용을 정확히 3문장으로 요약해주세요.
## 현재 회의
제목: {current_meeting_title}
날짜: {current_meeting_date}
안건:
{current_meeting_agendas}
## 과거 회의
제목: {past_meeting_title}
날짜: {past_meeting_date}
내용:
{past_meeting_content}
## 요구사항
1. 두 회의에서 공통적으로 논의된 주제나 결정사항을 찾아주세요
2. 정확히 3문장으로 요약하세요 (각 문장은 한 문단)
3. 구체적인 내용을 포함해주세요 (예: 날짜, 수치, 결정사항)
4. 과거 회의에서 실제로 다뤄진 내용만 포함해주세요 (환각 금지)
"""
try:
response = self.client.messages.create(
model=self.model,
max_tokens=self.max_tokens,
temperature=self.temperature,
system=system_prompt,
messages=[
{"role": "user", "content": user_prompt}
]
)
summary = response.content[0].text
tokens_used = response.usage.input_tokens + response.usage.output_tokens
return {
"summary": summary,
"generated_by": self.model,
"tokens_used": tokens_used,
"cached": False
}
except Exception as e:
logger.error(f"Claude API 호출 실패: {str(e)}")
return {
"summary": None,
"generated_by": "fallback",
"tokens_used": 0,
"cached": False,
"error": str(e)
}
+335
View File
@@ -0,0 +1,335 @@
"""
Azure Event Hub Consumer 서비스
회의록 확정 이벤트를 consume하여 RAG 저장소에 저장
"""
import asyncio
import json
import logging
from typing import Dict, Any, Optional, Union, List
from datetime import datetime
from azure.eventhub.aio import EventHubConsumerClient
from azure.eventhub.extensions.checkpointstoreblobaio import BlobCheckpointStore
from ..models.minutes import RagMinutes, MinutesSection
from ..db.rag_minutes_db import RagMinutesDB
from ..utils.embedding import EmbeddingGenerator
logger = logging.getLogger(__name__)
class EventHubConsumer:
"""Event Hub Consumer 서비스"""
def __init__(
self,
connection_string: str,
eventhub_name: str,
consumer_group: str,
storage_connection_string: str,
storage_container_name: str,
rag_minutes_db: RagMinutesDB,
embedding_gen: EmbeddingGenerator
):
"""
초기화
Args:
connection_string: Event Hub 연결 문자열
eventhub_name: Event Hub 이름
consumer_group: Consumer Group 이름
storage_connection_string: Azure Storage 연결 문자열
storage_container_name: Checkpoint 저장 컨테이너 이름
rag_minutes_db: RAG Minutes 데이터베이스
embedding_gen: Embedding 생성기
"""
self.connection_string = connection_string
self.eventhub_name = eventhub_name
self.consumer_group = consumer_group
self.storage_connection_string = storage_connection_string
self.storage_container_name = storage_container_name
self.rag_minutes_db = rag_minutes_db
self.embedding_gen = embedding_gen
self.client: Optional[EventHubConsumerClient] = None
self.is_running = False
async def start(self):
"""Consumer 시작"""
try:
# Checkpoint Store 생성
checkpoint_store = BlobCheckpointStore.from_connection_string(
self.storage_connection_string,
self.storage_container_name
)
# Event Hub Consumer Client 생성
self.client = EventHubConsumerClient.from_connection_string(
self.connection_string,
consumer_group=self.consumer_group,
eventhub_name=self.eventhub_name,
checkpoint_store=checkpoint_store
)
self.is_running = True
logger.info("Event Hub Consumer 시작")
# 이벤트 수신 시작
async with self.client:
await self.client.receive(
on_event=self._on_event,
on_error=self._on_error,
starting_position="-1" # 처음부터 읽기
)
except Exception as e:
logger.error(f"Event Hub Consumer 시작 실패: {str(e)}")
self.is_running = False
raise
async def stop(self):
"""Consumer 중지"""
self.is_running = False
if self.client:
await self.client.close()
logger.info("Event Hub Consumer 중지")
async def _on_event(self, partition_context, event):
"""
이벤트 수신 핸들러
Args:
partition_context: 파티션 컨텍스트
event: Event Hub 이벤트
"""
try:
# 이벤트 데이터 파싱
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')}")
# 회의록 확정 이벤트 처리
if event_data.get("eventType") == "MINUTES_FINALIZED":
await self._process_minutes_event(event_data)
# Checkpoint 업데이트
await partition_context.update_checkpoint(event)
except json.JSONDecodeError as e:
logger.error(f"이벤트 파싱 실패: {str(e)}")
except Exception as e:
logger.error(f"이벤트 처리 실패: {str(e)}")
async def _on_error(self, partition_context, error):
"""
에러 핸들러
Args:
partition_context: 파티션 컨텍스트
error: 에러 객체
"""
logger.error(f"Event Hub 에러 (Partition {partition_context.partition_id}): {str(error)}")
def _convert_datetime_array_to_string(self, value: Union[str, List, None]) -> Optional[str]:
"""
Java LocalDateTime 배열을 ISO 8601 문자열로 변환
Java의 Jackson이 LocalDateTime을 배열 형식으로 직렬화할 때 사용
배열 형식: [년, 월, 일, 시, 분, 초, 나노초]
Args:
value: datetime 값 (str, list, None)
Returns:
ISO 8601 형식 문자열 또는 None
Examples:
>>> _convert_datetime_array_to_string([2025, 11, 1, 13, 55, 54, 388000000])
"2025-11-01T13:55:54.388000"
>>> _convert_datetime_array_to_string("2025-11-01T13:55:54.388")
"2025-11-01T13:55:54.388"
>>> _convert_datetime_array_to_string(None)
None
"""
if value is None:
return None
# 이미 문자열이면 그대로 반환
if isinstance(value, str):
return value
# 배열 형식 [년, 월, 일, 시, 분, 초, 나노초]
if isinstance(value, list) and len(value) >= 6:
try:
year, month, day, hour, minute, second = value[:6]
# 나노초를 마이크로초로 변환 (Python datetime은 마이크로초 사용)
microsecond = value[6] // 1000 if len(value) > 6 else 0
dt = datetime(year, month, day, hour, minute, second, microsecond)
return dt.isoformat()
except (ValueError, TypeError) as e:
logger.warning(f"날짜 배열 변환 실패: {value}, 에러: {str(e)}")
return None
logger.warning(f"지원하지 않는 날짜 형식: {type(value)}, 값: {value}")
return None
async def _process_minutes_event(self, event_data: Dict[str, Any]):
"""
회의록 확정 이벤트 처리
Args:
event_data: 이벤트 데이터
"""
try:
# 회의록 데이터 추출
minutes_data = event_data.get("data", {})
# Meeting 정보
meeting_id = minutes_data.get("meetingId")
title = minutes_data.get("title")
purpose = minutes_data.get("purpose")
description = minutes_data.get("description")
# Java LocalDateTime 배열을 문자열로 변환
scheduled_at = self._convert_datetime_array_to_string(
minutes_data.get("scheduledAt")
)
location = minutes_data.get("location")
organizer_id = minutes_data.get("organizerId")
# Minutes 정보
minutes_id = minutes_data.get("minutesId")
minutes_status = minutes_data.get("status", "FINALIZED")
minutes_version = minutes_data.get("version", 1)
created_by = minutes_data.get("createdBy")
finalized_by = minutes_data.get("finalizedBy")
# Java LocalDateTime 배열을 문자열로 변환
finalized_at = self._convert_datetime_array_to_string(
minutes_data.get("finalizedAt")
)
# Sections 정보
sections_data = minutes_data.get("sections", [])
sections = [
MinutesSection(
section_id=section.get("sectionId"),
type=section.get("type"),
title=section.get("title"),
content=section.get("content", ""),
order=section.get("order", 0),
verified=section.get("verified", False)
)
for section in sections_data
]
# 전체 회의록 내용 생성 (검색용)
full_content = self._generate_full_content(title, purpose, sections)
logger.info(f"회의록 내용 생성 완료: {len(full_content)} 글자")
# Embedding 생성
logger.info(f"Embedding 생성 시작: {minutes_id}")
embedding = self.embedding_gen.generate_embedding(full_content)
logger.info(f"Embedding 생성 완료: {len(embedding)} 차원")
# RagMinutes 객체 생성
rag_minutes = RagMinutes(
meeting_id=meeting_id,
title=title,
purpose=purpose,
description=description,
scheduled_at=scheduled_at,
location=location,
organizer_id=organizer_id,
minutes_id=minutes_id,
minutes_status=minutes_status,
minutes_version=minutes_version,
created_by=created_by,
finalized_by=finalized_by,
finalized_at=finalized_at,
sections=sections,
full_content=full_content,
embedding=embedding,
created_at=datetime.now().isoformat(),
updated_at=datetime.now().isoformat()
)
# 데이터베이스에 저장
success = self.rag_minutes_db.insert_minutes(rag_minutes)
if success:
logger.info(f"회의록 RAG 저장 성공: {minutes_id}")
else:
logger.error(f"회의록 RAG 저장 실패: {minutes_id}")
except Exception as e:
logger.error(f"회의록 이벤트 처리 실패: {str(e)}")
raise
def _generate_full_content(self, title: str, purpose: Optional[str], sections: list) -> str:
"""
전체 회의록 내용 생성 (검색용 텍스트)
Args:
title: 회의 제목
purpose: 회의 목적
sections: 회의록 섹션 목록
Returns:
전체 회의록 내용
"""
content_parts = []
# 제목
if title:
content_parts.append(f"제목: {title}")
# 목적
if purpose:
content_parts.append(f"목적: {purpose}")
# 섹션별 내용
for section in sections:
if section.content:
content_parts.append(f"\n[{section.title}]\n{section.content}")
return "\n\n".join(content_parts)
async def start_consumer(
config: Dict[str, Any],
rag_minutes_db: RagMinutesDB,
embedding_gen: EmbeddingGenerator
):
"""
Event Hub Consumer 시작 (비동기)
Args:
config: 설정 딕셔너리
rag_minutes_db: RAG Minutes 데이터베이스
embedding_gen: Embedding 생성기
"""
eventhub_config = config["eventhub"]
consumer = EventHubConsumer(
connection_string=eventhub_config["connection_string"],
eventhub_name=eventhub_config["name"],
consumer_group=eventhub_config["consumer_group"],
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
)
try:
await consumer.start()
except KeyboardInterrupt:
logger.info("Consumer 종료 신호 수신")
await consumer.stop()
except Exception as e:
logger.error(f"Consumer 실행 중 에러: {str(e)}")
await consumer.stop()
raise