517 lines
15 KiB
Markdown
517 lines
15 KiB
Markdown
# Email Skill Callback Architecture 구현 완료 보고서
|
|
|
|
## 작업일: 2025-08-20
|
|
## 작업자: 희재
|
|
## 상태: 완료
|
|
|
|
---
|
|
|
|
## 1. 작업 개요
|
|
|
|
### 배경 및 문제점
|
|
- **초기 문제**: skill-email이 독립적으로 LLM 서비스(포트 8003)를 호출하는 구조
|
|
- **다중 로빙 충돌**: rb8001과 rb8002가 동시에 skill-email 사용 시 전역 handler 공유로 대화 상태 혼재
|
|
- **LLM 중복**: 각 서비스가 개별 LLM을 호출하는 비효율적 구조
|
|
|
|
### 해결 방안
|
|
- **Callback 패턴**: skill-email이 필요시 로빙의 LLM을 callback으로 호출
|
|
- **로빙별 Handler**: 각 로빙이 독립적인 conversation handler 인스턴스 사용
|
|
- **중앙집중 LLM**: 각 로빙이 자신의 LLM을 제공하여 일관된 처리
|
|
|
|
---
|
|
|
|
## 2. 아키텍처 변경
|
|
|
|
### 이전 구조 (단방향)
|
|
```
|
|
rb8001 → skill-email → LLM Service (8003)
|
|
rb8002 → skill-email → LLM Service (8003) [같은 서비스]
|
|
↑
|
|
전역 handler (충돌!)
|
|
```
|
|
|
|
### 새로운 구조 (Callback)
|
|
```
|
|
rb8001 → skill-email (callback URL 전달)
|
|
↓
|
|
handler["rb8001"] 생성/선택
|
|
↓
|
|
LLM 필요시 → rb8001/api/llm/extract 호출
|
|
↑
|
|
rb8001의 LLM이 처리
|
|
|
|
rb8002 → skill-email (callback URL 전달 또는 없음)
|
|
↓
|
|
handler["rb8002"] 생성/선택 (독립 인스턴스)
|
|
↓
|
|
자체 파싱 또는 rb8002의 LLM 호출
|
|
```
|
|
|
|
---
|
|
|
|
## 3. 구현 내용
|
|
|
|
### 3.1 skill-email 변경사항
|
|
|
|
#### A. 로빙별 Handler 관리 (`/home/heejae/skill-email/main.py`)
|
|
```python
|
|
# 이전: 전역 단일 handler
|
|
conversation_handler = ConversationHandler()
|
|
|
|
# 변경: 로빙별 독립 handler
|
|
conversation_handlers = {} # robing_id별 핸들러 딕셔너리
|
|
|
|
@app.post("/process")
|
|
async def process_email_conversation(request: Request):
|
|
data = await request.json()
|
|
robing_id = data.get("robing_id", "default")
|
|
llm_callback = data.get("llm_callback")
|
|
|
|
# 로빙별 핸들러 가져오기 또는 생성
|
|
if robing_id not in conversation_handlers:
|
|
conversation_handlers[robing_id] = ConversationHandler(
|
|
llm_callback_url=llm_callback
|
|
)
|
|
else:
|
|
# callback URL 업데이트
|
|
handler = conversation_handlers[robing_id]
|
|
if llm_callback and handler.llm_callback_url != llm_callback:
|
|
handler.llm_callback_url = llm_callback
|
|
|
|
handler = conversation_handlers[robing_id]
|
|
result = await handler.process_message(...)
|
|
```
|
|
|
|
#### B. ConversationHandler Callback 지원 (`/home/heejae/skill-email/handlers/conversation_handler.py`)
|
|
|
|
**1. 초기화 변경**
|
|
```python
|
|
# 이전
|
|
def __init__(self, llm_service_url: str = "http://localhost:8003"):
|
|
self.llm_service_url = llm_service_url
|
|
|
|
# 변경
|
|
def __init__(self, llm_callback_url: Optional[str] = None):
|
|
self.llm_callback_url = llm_callback_url # 로빙의 LLM callback URL
|
|
```
|
|
|
|
**2. LLM 추출 로직**
|
|
```python
|
|
async def _extract_email_info(self, message: str, robing_id: str,
|
|
llm_hints: Optional[Dict] = None) -> Dict:
|
|
"""이메일 정보 추출 - callback URL 또는 자체 파싱 사용"""
|
|
|
|
# 1. Callback URL이 있으면 로빙의 LLM 사용
|
|
if self.llm_callback_url:
|
|
try:
|
|
response = await self.http_client.post(
|
|
self.llm_callback_url,
|
|
json={
|
|
"message": message,
|
|
"task": "extract_email_info",
|
|
"robing_id": robing_id
|
|
}
|
|
)
|
|
|
|
if response.status_code == 200:
|
|
result = response.json()
|
|
extracted = result.get("result", {})
|
|
|
|
# 자체 파싱으로 보완 (이메일 주소는 정규식으로 확실하게)
|
|
parsed = self._parse_email_info(message, llm_hints)
|
|
|
|
# 병합 (파싱된 이메일 주소 우선)
|
|
return {
|
|
"to": parsed.get("to") or extracted.get("to"),
|
|
"subject": extracted.get("subject") or parsed.get("subject"),
|
|
"body": extracted.get("body") or parsed.get("body")
|
|
}
|
|
except Exception as e:
|
|
logger.error(f"LLM callback error: {e}")
|
|
|
|
# 2. Callback이 없거나 실패하면 자체 파싱
|
|
return self._parse_email_info(message, llm_hints)
|
|
```
|
|
|
|
**3. 이메일 주소 필수 요구**
|
|
```python
|
|
def _parse_email_info(self, message: str, llm_hints: Optional[Dict] = None) -> Dict:
|
|
"""이메일 정보 파싱 - 이메일 주소는 정규식으로만 추출"""
|
|
import re
|
|
|
|
info = {"to": None, "subject": None, "body": None}
|
|
|
|
# 1. 정규식으로 이메일 주소 찾기 (필수) - 더 유연한 패턴
|
|
email_pattern = r'[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}'
|
|
emails = re.findall(email_pattern, message)
|
|
if emails:
|
|
info["to"] = emails[0]
|
|
# 이름만 있으면 None 유지 - 명시적 이메일 주소 요구
|
|
|
|
# 2. 제목/본문은 키워드 또는 LLM 힌트 사용
|
|
# ...
|
|
|
|
return info
|
|
```
|
|
|
|
### 3.2 rb8001 변경사항
|
|
|
|
#### A. LLM Callback 엔드포인트 추가 (`/home/heejae/rb8001/main.py`)
|
|
```python
|
|
@app.post("/api/llm/extract")
|
|
async def llm_extract_callback(request: Request):
|
|
"""skill-email이 사용하는 LLM callback 엔드포인트"""
|
|
try:
|
|
data = await request.json()
|
|
message = data.get("message", "")
|
|
task = data.get("task", "")
|
|
robing_id = data.get("robing_id", "")
|
|
|
|
if task == "extract_email_info":
|
|
# rb8001의 LLM으로 이메일 정보 추출
|
|
prompt = f"""
|
|
사용자의 이메일 요청에서 정보를 추출해주세요.
|
|
|
|
메시지: "{message}"
|
|
|
|
다음 정보를 찾아주세요:
|
|
1. 수신자 이메일 주소 (이메일 형식만, 이름은 제외)
|
|
2. 이메일 제목 (추측 가능하면)
|
|
3. 이메일 본문 (추측 가능하면)
|
|
|
|
JSON 형식으로 응답:
|
|
{{
|
|
"to": "이메일 주소 또는 null",
|
|
"subject": "제목 또는 null",
|
|
"body": "본문 또는 null"
|
|
}}
|
|
"""
|
|
# LLM 서비스 호출
|
|
result = await router._call_internal_llm(
|
|
message=prompt,
|
|
user_id="system",
|
|
channel="system"
|
|
)
|
|
|
|
# 응답 파싱 및 JSON 추출
|
|
content = result.get("content", "{}")
|
|
extracted = {"to": None, "subject": None, "body": None}
|
|
|
|
try:
|
|
# JSON 블록 파싱
|
|
if "```json" in content:
|
|
json_start = content.find("```json") + 7
|
|
json_end = content.find("```", json_start)
|
|
json_str = content[json_start:json_end].strip()
|
|
else:
|
|
json_str = content
|
|
|
|
extracted = json.loads(json_str)
|
|
except:
|
|
# 휴리스틱 fallback
|
|
if "회의" in message:
|
|
extracted["subject"] = "회의 일정 안내"
|
|
elif "감사" in message:
|
|
extracted["subject"] = "감사 인사"
|
|
|
|
return {"result": extracted}
|
|
|
|
else:
|
|
# 다른 task는 일반 처리
|
|
result = await router._call_internal_llm(
|
|
message=message,
|
|
user_id="system",
|
|
channel="system"
|
|
)
|
|
return {"result": result.get("content", "")}
|
|
|
|
except Exception as e:
|
|
logger.error(f"LLM callback error: {e}")
|
|
return {"error": str(e), "result": {}}
|
|
```
|
|
|
|
#### B. skill-email 호출 시 Callback URL 전달 (`/home/heejae/rb8001/app/skills/email_integration.py`)
|
|
```python
|
|
async def process_email_request(self, message: str, user_id: str, channel: str = None):
|
|
# ...
|
|
|
|
# skill-email 서비스 호출
|
|
async with httpx.AsyncClient(timeout=30.0) as client:
|
|
if email_intent["action"] == "send":
|
|
response = await client.post(
|
|
f"{self.skill_email_url}/process",
|
|
json={
|
|
"message": message,
|
|
"user_id": user_id,
|
|
"channel": channel or "",
|
|
"robing_id": "rb8001",
|
|
"llm_hints": llm_hints,
|
|
"llm_callback": "http://localhost:8001/api/llm/extract" # ← Callback URL
|
|
}
|
|
)
|
|
```
|
|
|
|
---
|
|
|
|
## 4. 핵심 원칙 구현
|
|
|
|
### 4.1 책임 분리
|
|
- **rb8001 (및 다른 로빙)**: 고수준 추론, 의도 파악, LLM 제공
|
|
- **skill-email**: 이메일 도메인 전문 처리 (파싱, 검증, 발송)
|
|
|
|
### 4.2 이메일 주소 필수 정책
|
|
- 이름만으로는 절대 발송하지 않음
|
|
- 명시적 이메일 주소가 없으면 반드시 요청
|
|
- 예: "종태님한테" → "받는 사람의 이메일 주소를 알려주세요. (예: example@gmail.com)"
|
|
|
|
### 4.3 다중 로빙 지원
|
|
- 각 로빙이 독립적인 handler 인스턴스 사용
|
|
- 대화 상태가 섞이지 않음
|
|
- 각자의 LLM callback 사용 가능
|
|
|
|
---
|
|
|
|
## 5. 테스트 결과
|
|
|
|
### 5.1 Callback 엔드포인트 테스트
|
|
```bash
|
|
curl -X POST http://localhost:8001/api/llm/extract \
|
|
-H "Content-Type: application/json" \
|
|
-d '{"message": "종태님한테 회의 메일 보내줘", "task": "extract_email_info", "robing_id": "rb8001"}'
|
|
|
|
# 응답
|
|
{
|
|
"result": {
|
|
"to": null,
|
|
"subject": "회의",
|
|
"body": "회의 요청드립니다."
|
|
}
|
|
}
|
|
```
|
|
|
|
### 5.2 통합 테스트
|
|
```python
|
|
# test_callback_integration.py 실행 결과
|
|
|
|
# 1. 이메일 주소 파싱 ✓
|
|
Message: test@example.com에게 회의 메일 보내줘
|
|
Response: 받는 사람: test@example.com
|
|
이메일 제목을 알려주세요...
|
|
|
|
# 2. LLM Callback 사용 ✓
|
|
rb8001 with callback URL → LLM이 제목 추출
|
|
rb8002 without callback → 자체 파싱만 사용
|
|
|
|
# 3. 세션 독립성 ✓
|
|
rb8001 draft to: admin@company.com
|
|
rb8002 draft to: test@example.com
|
|
(각자 독립적인 대화 상태 유지)
|
|
```
|
|
|
|
---
|
|
|
|
## 6. 주요 개선 사항
|
|
|
|
### 이전 문제점
|
|
1. **LLM 중복 호출**: skill-email이 독립적으로 LLM 서비스 호출
|
|
2. **전역 handler 충돌**: 여러 로빙이 같은 handler 공유
|
|
3. **이름-이메일 매핑**: 복잡한 디렉토리 관리 필요
|
|
|
|
### 해결된 내용
|
|
1. **Callback 패턴**: 로빙이 자신의 LLM 제공
|
|
2. **로빙별 독립 handler**: 대화 상태 완전 분리
|
|
3. **명시적 이메일 주소 요구**: 안전하고 명확한 처리
|
|
|
|
---
|
|
|
|
## 7. 디렉토리 구조
|
|
|
|
```
|
|
/home/heejae/
|
|
├── rb8001/
|
|
│ ├── main.py # /api/llm/extract 엔드포인트 추가
|
|
│ ├── app/skills/
|
|
│ │ └── email_integration.py # llm_callback URL 전달
|
|
│ └── test_callback_integration.py # 통합 테스트
|
|
│
|
|
└── skill-email/
|
|
├── main.py # 로빙별 handler 관리
|
|
└── handlers/
|
|
└── conversation_handler.py # Callback URL 지원
|
|
```
|
|
|
|
---
|
|
|
|
## 8. 데이터 플로우
|
|
|
|
### 8.1 초기 요청
|
|
```
|
|
사용자: "종태님한테 회의 메일 보내줘"
|
|
↓
|
|
rb8001:
|
|
- Gmail 장착 확인
|
|
- 의도 파악
|
|
- skill-email 호출 (callback URL 포함)
|
|
```
|
|
|
|
### 8.2 skill-email 처리
|
|
```
|
|
skill-email:
|
|
- robing_id로 handler 선택/생성
|
|
- callback URL 설정
|
|
- 이메일 정보 추출 필요
|
|
↓
|
|
rb8001/api/llm/extract 호출
|
|
↓
|
|
rb8001 LLM: 제목 "회의 일정 안내" 추출
|
|
↓
|
|
자체 파싱: 이메일 주소 없음 확인
|
|
↓
|
|
응답: "받는 사람의 이메일 주소를 알려주세요"
|
|
```
|
|
|
|
### 8.3 연속 대화
|
|
```
|
|
사용자: "goeun2dc@gmail.com"
|
|
↓
|
|
skill-email:
|
|
- 기존 handler["rb8001"] 사용
|
|
- draft 업데이트
|
|
- 이메일 주소 파싱 성공
|
|
- 제목은 이미 LLM이 추출함
|
|
↓
|
|
응답: "이메일 본문 내용을 알려주세요"
|
|
```
|
|
|
|
---
|
|
|
|
## 9. 보안 및 안정성
|
|
|
|
### 9.1 보안 강화
|
|
- 이메일 주소 명시적 확인 (이름만으로 발송 방지)
|
|
- 로빙별 독립 세션 (정보 혼재 방지)
|
|
- PostgreSQL 기반 토큰 관리 (중앙집중식)
|
|
|
|
### 9.2 안정성 개선
|
|
- Callback 실패 시 자체 파싱 fallback
|
|
- 타임아웃 설정 (30초)
|
|
- 에러 처리 및 로깅
|
|
|
|
---
|
|
|
|
## 10. 성능 최적화
|
|
|
|
1. **Handler 재사용**: 로빙별로 한 번 생성 후 계속 사용
|
|
2. **선택적 LLM 호출**: 필요한 경우에만 callback
|
|
3. **병렬 처리 가능**: 여러 로빙이 동시 사용 가능
|
|
|
|
---
|
|
|
|
## 11. 향후 확장 가능성
|
|
|
|
### 11.1 WebSocket 지원
|
|
```python
|
|
# 실시간 양방향 통신
|
|
@app.websocket("/ws/{robing_id}")
|
|
async def websocket_endpoint(websocket: WebSocket, robing_id: str):
|
|
# 지속적인 대화 세션
|
|
```
|
|
|
|
### 11.2 다양한 Callback Task
|
|
```python
|
|
# 이메일 외 다른 정보 추출
|
|
if task == "extract_calendar_info":
|
|
# 일정 정보 추출
|
|
elif task == "extract_contact_info":
|
|
# 연락처 정보 추출
|
|
```
|
|
|
|
### 11.3 로빙별 커스터마이징
|
|
```python
|
|
# 각 로빙의 특성에 맞는 LLM 프롬프트
|
|
robing_prompts = {
|
|
"rb8001": "정중하고 격식있게",
|
|
"rb8002": "친근하고 캐주얼하게"
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 12. 배포 및 운영
|
|
|
|
### 12.1 Docker 재빌드
|
|
```bash
|
|
# rb8001
|
|
cd /home/heejae/rb8001
|
|
docker compose down
|
|
docker compose up -d --build
|
|
|
|
# skill-email
|
|
cd /home/heejae/skill-email
|
|
docker stop skill-email && docker rm skill-email
|
|
docker build -t skill-email .
|
|
docker run -d --name skill-email -p 8501:8501 --env-file .env skill-email
|
|
```
|
|
|
|
### 12.2 헬스체크
|
|
```bash
|
|
# rb8001 callback 엔드포인트
|
|
curl http://localhost:8001/api/llm/extract
|
|
|
|
# skill-email
|
|
curl http://localhost:8501/health
|
|
```
|
|
|
|
---
|
|
|
|
## 13. 문제 해결 기록
|
|
|
|
### 13.1 이메일 주소 파싱 실패
|
|
- **문제**: `\b` word boundary가 한글과 충돌
|
|
- **해결**: 정규식 패턴 단순화
|
|
```python
|
|
# 이전: r'\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b'
|
|
# 변경: r'[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}'
|
|
```
|
|
|
|
### 13.2 action 파라미터 오류
|
|
- **문제**: `_call_internal_llm()` got unexpected keyword argument 'action'
|
|
- **해결**: action 파라미터 제거
|
|
|
|
### 13.3 컨테이너 재시작 문제
|
|
- **문제**: 코드 변경이 적용되지 않음
|
|
- **해결**: docker compose down & up --build 사용
|
|
|
|
---
|
|
|
|
## 14. 검증 체크리스트
|
|
|
|
- [x] LLM callback 엔드포인트 동작
|
|
- [x] 로빙별 handler 독립성
|
|
- [x] 이메일 주소 필수 요구
|
|
- [x] Callback URL 전달
|
|
- [x] Fallback 자체 파싱
|
|
- [x] 세션 격리 테스트
|
|
- [x] 다중 로빙 동시 사용
|
|
- [x] 에러 처리 동작
|
|
|
|
---
|
|
|
|
## 15. 결론
|
|
|
|
### 성과
|
|
1. **진정한 Callback 아키텍처** 구현
|
|
2. **다중 로빙 완벽 지원**
|
|
3. **이메일 보안 강화** (명시적 주소 요구)
|
|
4. **확장 가능한 구조** 확립
|
|
|
|
### 핵심 가치
|
|
- **독립성**: 각 로빙이 독립적으로 동작
|
|
- **유연성**: Callback 있어도/없어도 동작
|
|
- **안전성**: 명확한 이메일 주소 확인
|
|
- **확장성**: 새로운 로빙 추가 용이
|
|
|
|
---
|
|
|
|
**작업 완료: 2025-08-20**
|
|
**다음 단계: 실제 Gmail API 발송 테스트 및 프로덕션 배포** |