DOCS/plans/completed/250820_email_callback_architecture_completed.md

15 KiB

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)

# 이전: 전역 단일 handler
conversation_handler = ConversationHandler()

# 변경: 로빙별 독립 handler
conversation_handlers = {}  # robeing_id별 핸들러 딕셔너리

@app.post("/process")
async def process_email_conversation(request: Request):
    data = await request.json()
    robeing_id = data.get("robeing_id", "default")
    llm_callback = data.get("llm_callback")
    
    # 로빙별 핸들러 가져오기 또는 생성
    if robeing_id not in conversation_handlers:
        conversation_handlers[robeing_id] = ConversationHandler(
            llm_callback_url=llm_callback
        )
    else:
        # callback URL 업데이트
        handler = conversation_handlers[robeing_id]
        if llm_callback and handler.llm_callback_url != llm_callback:
            handler.llm_callback_url = llm_callback
    
    handler = conversation_handlers[robeing_id]
    result = await handler.process_message(...)

B. ConversationHandler Callback 지원 (/home/heejae/skill-email/handlers/conversation_handler.py)

1. 초기화 변경

# 이전
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 추출 로직

async def _extract_email_info(self, message: str, robeing_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",
                    "robeing_id": robeing_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. 이메일 주소 필수 요구

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)

@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", "")
        robeing_id = data.get("robeing_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)

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 "",
                    "robeing_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 엔드포인트 테스트

curl -X POST http://localhost:8001/api/llm/extract \
  -H "Content-Type: application/json" \
  -d '{"message": "종태님한테 회의 메일 보내줘", "task": "extract_email_info", "robeing_id": "rb8001"}'

# 응답
{
    "result": {
        "to": null,
        "subject": "회의",
        "body": "회의 요청드립니다."
    }
}

5.2 통합 테스트

# 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:
  - robeing_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 지원

# 실시간 양방향 통신
@app.websocket("/ws/{robeing_id}")
async def websocket_endpoint(websocket: WebSocket, robeing_id: str):
    # 지속적인 대화 세션

11.2 다양한 Callback Task

# 이메일 외 다른 정보 추출
if task == "extract_calendar_info":
    # 일정 정보 추출
elif task == "extract_contact_info":
    # 연락처 정보 추출

11.3 로빙별 커스터마이징

# 각 로빙의 특성에 맞는 LLM 프롬프트
robeing_prompts = {
    "rb8001": "정중하고 격식있게",
    "rb8002": "친근하고 캐주얼하게"
}

12. 배포 및 운영

12.1 Docker 재빌드

# 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 헬스체크

# rb8001 callback 엔드포인트
curl http://localhost:8001/api/llm/extract

# skill-email
curl http://localhost:8501/health

13. 문제 해결 기록

13.1 이메일 주소 파싱 실패

  • 문제: \b word boundary가 한글과 충돌
  • 해결: 정규식 패턴 단순화
# 이전: 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. 검증 체크리스트

  • LLM callback 엔드포인트 동작
  • 로빙별 handler 독립성
  • 이메일 주소 필수 요구
  • Callback URL 전달
  • Fallback 자체 파싱
  • 세션 격리 테스트
  • 다중 로빙 동시 사용
  • 에러 처리 동작

15. 결론

성과

  1. 진정한 Callback 아키텍처 구현
  2. 다중 로빙 완벽 지원
  3. 이메일 보안 강화 (명시적 주소 요구)
  4. 확장 가능한 구조 확립

핵심 가치

  • 독립성: 각 로빙이 독립적으로 동작
  • 유연성: Callback 있어도/없어도 동작
  • 안전성: 명확한 이메일 주소 확인
  • 확장성: 새로운 로빙 추가 용이

작업 완료: 2025-08-20 다음 단계: 실제 Gmail API 발송 테스트 및 프로덕션 배포