This commit is contained in:
0914eagle 2025-08-20 16:52:27 +09:00
parent 18ac5845b4
commit 67f9d45547
2 changed files with 917 additions and 0 deletions

View File

@ -0,0 +1,400 @@
# rb8001과 skill-email Gmail 통합 완료 보고서
## 작업일: 2025-08-19
## 작업자: 희재
## 상태: 완료
---
## 1. 작업 개요
### 목적
rb8001이 Gmail 기능을 사용할 수 있도록 skill-email 서비스와 통합
### 배경
- skill-email은 DB 기반 토큰 관리로 전환 완료
- rb8001이 사용자의 Gmail 요청을 감지하고 처리할 수 있도록 통합 필요
- Gmail 아이템 장착 상태 확인 및 권한 관리 필요
---
## 2. 구현 내용
### 2.1 환경변수 설정
**파일**: `/home/heejae/rb8001/.env`
```bash
# Skill Services
SKILL_EMAIL_URL=http://localhost:8501
MONITOR_SERVICE_URL=http://localhost:9024
# PostgreSQL for Gmail tokens (SSH tunnel)
POSTGRES_CONNECTION_STRING=postgresql://robeings:robeings@localhost:5433/auth_db
```
### 2.2 Gmail 통합 모듈 구현
**파일**: `/home/heejae/rb8001/app/skills/email_integration.py`
#### 주요 클래스: EmailIntegration
Gmail 스킬 통합을 담당하는 핵심 모듈
#### 핵심 메서드
1. **check_gmail_equipped(user_id)**
```python
async def check_gmail_equipped(self, user_id: str) -> bool:
# PostgreSQL에서 직접 장착 상태 확인
# 5분 캐싱으로 성능 최적화
cur.execute("""
SELECT COUNT(*) FROM gmail_tokens
WHERE user_id = %s AND is_equipped = true
""", (user_id,))
```
- gmail_tokens 테이블의 is_equipped 확인
- 캐시 TTL: 300초 (5분)
- DB 연결 실패 시 False 반환
2. **parse_email_intent(message)**
```python
def parse_email_intent(self, message: str) -> Optional[Dict[str, Any]]:
# 이메일 관련 키워드 감지
# 발송/조회/답장 의도 분류
# 수신자, 제목, 내용 추출
```
- 키워드: "이메일", "메일", "보내", "전송", "확인", "조회", "답장"
- 정규표현식으로 수신자 추출: `([가-힣]+님?)(?:한테|에게|께)`
- 이메일 주소 패턴: `[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}`
3. **process_email_request(message, user_id, channel)**
```python
async def process_email_request(
self,
message: str,
user_id: str,
channel: str = None
) -> Tuple[bool, str]:
# 1. Gmail 장착 확인
# 2. 이메일 의도 파싱
# 3. skill-email 서비스 호출
# 4. 응답 처리
```
**처리 플로우:**
- 미장착 시: "Gmail 패스포트를 먼저 장착해주세요! 🎫"
- 발송 요청: POST /process 엔드포인트 호출
- 조회 요청: GET /messages 엔드포인트 호출
- 토큰 만료: 재인증 안내 메시지
4. **handle_reauth(user_id)**
```python
async def handle_reauth(self, user_id: str) -> Tuple[bool, str]:
# robeing-monitor 서비스에 재인증 요청
# OAuth URL 생성 및 반환
```
### 2.3 라우터 통합
**파일**: `/home/heejae/rb8001/app/router/router.py`
#### 변경 내용
```python
# Import 추가
from app.skills.email_integration import email_integration
# route_message 메서드에 Gmail 처리 추가
async def route_message(self, message: str, user_id: str, channel: str, thread_ts: str = None):
# 0. 슬래시 명령어 처리
# ...
# 0.5. Gmail 요청 확인 및 처리 (새로 추가)
email_result = await email_integration.process_email_request(message, user_id, channel)
if email_result[0] or email_result[1] is not None:
return {
"success": email_result[0],
"message": email_result[1],
"content": email_result[1],
"service": "gmail",
"execution_plan": {"intent": "email", "skills": ["email"]}
}
# 1. 기존 Brain 기반 라우팅
# ...
```
**처리 우선순위:**
1. 슬래시 명령어 (/memory, /stats 등)
2. Gmail 요청 (이메일 발송/조회)
3. 일반 Brain 기반 라우팅
---
## 3. 테스트 구현
### 3.1 통합 테스트 스크립트
**파일**: `/home/heejae/rb8001/test_gmail_integration.py`
#### 테스트 케이스
1. **장착 상태 확인**
- U091UNVE41M (전희재): is_equipped=True ✅
- U0925SXQFDK (종태): is_equipped=False ✅
2. **의도 파싱 테스트**
- "종태님한테 회의 일정 메일 보내줘" → action: send, to: 종태님 ✅
- "최근 메일 확인해줘" → action: list, limit: 5 ✅
3. **프로세스 테스트**
- 미장착 사용자 → 장착 안내 메시지 ✅
- 장착된 사용자 → skill-email 호출 ✅
### 3.2 테스트 결과
```bash
=== rb8001 Gmail Integration Test ===
[Test: 장착 상태 확인]
User U091UNVE41M: Gmail equipped = True
[Test: 이메일 발송 의도 파싱]
Parsed intent: {'action': 'send', 'to': '종태님', 'subject': '회의 일정 안내', 'body': '...'}
[Test: 이메일 조회 의도 파싱]
Parsed intent: {'action': 'list', 'limit': 5}
[Test: 이메일 처리 (미장착)]
Success: False
Message: Gmail 패스포트를 먼저 장착해주세요! 🎫
[Test: 이메일 처리 (장착됨)]
Success: True
Message: 이메일을 보내기 위해 다음 정보가 필요합니다...
```
---
## 4. API 통신
### 4.1 skill-email 엔드포인트
- **POST /process**: 대화형 이메일 처리
- **GET /messages**: 이메일 목록 조회
- **GET /health**: 서비스 상태 확인
### 4.2 요청/응답 형식
#### 발송 요청
```json
{
"message": "종태님한테 회의 일정 메일 보내줘",
"user_id": "U091UNVE41M",
"channel": "C123456",
"robing_id": "rb8001"
}
```
#### 응답
```json
{
"success": true,
"content": "이메일 처리 메시지",
"data": {
"type": "need_more_info|send|error",
"draft": {...},
"missing_fields": ["to", "subject"]
}
}
```
---
## 5. 보안 및 권한 관리
### 5.1 장착 확인
- is_equipped=true인 토큰만 사용 가능
- 사용자별 개별 확인 (Slack User ID 기반)
### 5.2 캐싱
- 장착 상태 5분 캐싱
- DB 부하 감소 및 응답 속도 개선
### 5.3 에러 처리
- DB 연결 실패 → 기본값 False (안전한 실패)
- 토큰 만료 → 재인증 안내
- 네트워크 타임아웃 → 30초 제한
---
## 6. 디렉토리 구조
```
/home/heejae/rb8001/
├── .env # 환경변수 (수정됨)
├── app/
│ ├── skills/ # 새로 생성
│ │ └── email_integration.py # Gmail 통합 모듈
│ ├── router/
│ │ └── router.py # 라우터 (수정됨)
│ └── ...
└── test_gmail_integration.py # 테스트 스크립트
```
---
## 7. 데이터 플로우
```
사용자 메시지 (Slack)
rb8001 (router.py)
Gmail 의도 감지
장착 상태 확인 (PostgreSQL)
skill-email 호출 (HTTP)
Gmail API 실행
응답 반환
Slack 메시지 전송
```
---
## 8. 주요 성과
1. **통합 완료**
- rb8001이 Gmail 요청 감지 및 처리 가능
- skill-email 서비스와 완전 연동
2. **권한 관리**
- Gmail 아이템 장착 상태 확인
- 미장착 시 친절한 안내 메시지
3. **성능 최적화**
- 5분 캐싱으로 DB 부하 감소
- 30초 타임아웃으로 무한 대기 방지
4. **유연한 구조**
- 의도 파싱과 서비스 호출 분리
- 확장 가능한 아키텍처
---
## 9. 문제 해결
### 9.1 권한 오류
- **문제**: `/home/heejae/rb8001/app/skills/` 디렉토리 생성 실패
- **해결**: `sudo mkdir -p``chmod 777` 적용
### 9.2 의도 파싱
- **문제**: "test@example.com에게" 형식 파싱 실패
- **해결**: 이메일 정규표현식 패턴 추가
### 9.3 모듈 임포트
- **문제**: psycopg2 모듈 위치
- **해결**: 함수 내부에서 동적 임포트
---
## 10. 사용 시나리오
### 시나리오 1: 이메일 발송
```
사용자: @rb8001 종태님한테 회의 일정 메일 보내줘
rb8001: (장착 확인) → (의도 파싱) → (skill-email 호출)
skill-email: 이메일 주소가 필요합니다. 종태님의 이메일은?
사용자: goeun2dc@gmail.com
skill-email: (이메일 발송) → 성공 메시지
```
### 시나리오 2: 미장착 사용자
```
사용자: @rb8001 이메일 보내줘
rb8001: Gmail 패스포트를 먼저 장착해주세요! 🎫
`/inventory` 명령어로 인벤토리를 확인할 수 있습니다.
```
### 시나리오 3: 메일 조회
```
사용자: @rb8001 최근 메일 확인해줘
rb8001: 📧 최근 이메일:
1. *회의 일정 안내*
발신: 김종태
날짜: 2025-08-19
...
```
---
## 11. 향후 개선사항
### 즉시 필요
- [ ] Docker 컨테이너 재빌드 및 배포
- [ ] Slack 실제 환경 테스트
- [ ] 에러 로깅 강화
### 추후 개선
- [ ] 사용자 이름 → 이메일 주소 자동 매핑
- [ ] 답장 기능 구현
- [ ] 첨부파일 지원
- [ ] 이메일 검색 기능
- [ ] 대화 컨텍스트 유지 (연속 대화)
---
## 12. 의존성
### Python 패키지
- httpx: 비동기 HTTP 클라이언트
- psycopg2-binary: PostgreSQL 연결
- asyncio: 비동기 처리
### 외부 서비스
- skill-email (포트 8501)
- PostgreSQL (SSH 터널 5433 → 5432)
- robeing-monitor (포트 9024, 선택적)
---
## 13. 참고 명령어
```bash
# 통합 테스트 실행
cd /home/heejae/rb8001 && python3 test_gmail_integration.py
# skill-email 상태 확인
curl http://localhost:8501/health
# 이메일 발송 테스트
curl -X POST http://localhost:8501/process \
-H "Content-Type: application/json" \
-d '{"message": "...", "user_id": "U091UNVE41M", ...}'
# PostgreSQL 장착 상태 확인
python3 -c "
import psycopg2
conn = psycopg2.connect('postgresql://robeings:robeings@localhost:5433/auth_db')
cur = conn.cursor()
cur.execute('SELECT user_id, is_equipped FROM gmail_tokens')
for row in cur.fetchall():
print(f'{row[0]}: equipped={row[1]}')
"
```
---
## 14. 검증 체크리스트
- [x] 환경변수 설정 완료
- [x] EmailIntegration 클래스 구현
- [x] 장착 상태 확인 동작
- [x] 의도 파싱 정확도
- [x] skill-email API 호출 성공
- [x] 라우터 통합 완료
- [x] 에러 처리 동작
- [x] 캐싱 메커니즘 동작
- [x] 테스트 스크립트 통과
- [ ] 실제 Gmail API 호출 (토큰 유효성)
- [ ] Slack 실환경 테스트
---
**작업 완료: 2025-08-19**
**다음 단계: Docker 배포 및 실환경 테스트**

View File

@ -0,0 +1,517 @@
# 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 발송 테스트 및 프로덕션 배포**