# 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 = {} # 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. 초기화 변경** ```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, 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. 이메일 주소 필수 요구** ```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", "") 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`) ```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 "", "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 엔드포인트 테스트 ```bash 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 통합 테스트 ```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: - 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 지원 ```python # 실시간 양방향 통신 @app.websocket("/ws/{robeing_id}") async def websocket_endpoint(websocket: WebSocket, robeing_id: str): # 지속적인 대화 세션 ``` ### 11.2 다양한 Callback Task ```python # 이메일 외 다른 정보 추출 if task == "extract_calendar_info": # 일정 정보 추출 elif task == "extract_contact_info": # 연락처 정보 추출 ``` ### 11.3 로빙별 커스터마이징 ```python # 각 로빙의 특성에 맞는 LLM 프롬프트 robeing_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 발송 테스트 및 프로덕션 배포**