# 캘린더 일정 등록 실패 문제 TDD 접근

**작성일**: 2025-11-22
**작성자**: Claude
**목적**: "11월 24일 월요일 오후 1시부터 4시까지 컴퍼니엑스 세미나" 등록 실패 문제 해결

---

## 1. 문제 상황

### 사용자 시나리오
```
사용자: "11월 24일 월요일 오후 1시부터 4시까지 컴퍼니엑스 세미나"
로빙: "네, 11월 24일 월요일 오후 1시부터 4시까지 컴퍼니엑스 세미나 일정을 등록해 드릴게요. 구글 캘린더에 등록해드릴까요?"
사용자: "ㅇㅇ"
로빙: "일정 등록에 실패했습니다. 날짜/시간 형식을 다시 확인해 주세요." ❌
```

### 로그 분석 (2025-11-22 11:23)

**rb8001 로그**:
```
[Calendar] Extracted - date: 2025-11-24, time_range: 13:00 ~ 16:00, location: None, title: 컴퍼니엑스 세미나
```
✅ **파싱 성공**: 날짜, 시간, 제목 모두 정상 추출

**skill-calendar 로그**:
```
Failed to create event: ('invalid_grant: Token has been expired or revoked.', {'error': 'invalid_grant', 'error_description': 'Token has been expired or revoked.'})
```
❌ **문제**: Google OAuth 토큰 만료/취소

---

## 2. 문제 원인 분석

### 2.1 rb8001 파싱 로직 (✅ 정상)
- 자연어 시간 범위 파싱: "오후 1시부터 4시까지" → "13:00 ~ 16:00" ✅
- 제목 추출: "컴퍼니엑스 세미나" ✅
- 날짜 추출: "11월 24일" → "2025-11-24" ✅

### 2.2 skill-calendar OAuth 토큰 문제 (❌ 실패)
- Google OAuth 토큰이 만료되었거나 취소됨
- 토큰 갱신 로직이 있지만 실패 (`invalid_grant` 에러)
- skill-email과 동일한 패턴이지만 skill-calendar에는 제대로 적용되지 않음

---

## 3. TDD 계획

### 3.1 테스트 작성 (Red Phase)

#### 테스트 1: 자연어 시간 범위 파싱
```python
def test_natural_language_time_range_parsing():
    test_cases = [
        ("오후 1시부터 4시까지", "13:00 ~ 16:00"),
        ("오전 9시부터 오후 5시까지", "09:00 ~ 17:00"),
        ("1시부터 4시까지", "01:00 ~ 04:00"),
    ]
    # 검증: extract_time_range_generic() 함수
```

#### 테스트 2: 제목 추출
```python
def test_title_extraction_from_original_message():
    test_cases = [
        ("11월 24일 월요일 오후 1시부터 4시까지 컴퍼니엑스 세미나", "컴퍼니엑스 세미나"),
    ]
    # 검증: 원본 메시지에서 날짜/시간 제거 후 제목 추출
```

#### 테스트 3: 전체 파싱 워크플로우
```python
def test_full_parsing_workflow():
    # 실제 사용자 입력 시나리오 전체 테스트
    # 날짜 추출 → 시간 범위 추출 → ISO 변환
```

#### 테스트 4: skill-calendar 토큰 갱신
```python
def test_token_refresh_on_expired():
    # 만료된 토큰에 대해 자동 갱신 시도
    # skill-email 패턴 참고
```

### 3.2 구현 (Green Phase)

#### 단계 1: rb8001 파싱 로직 개선 (✅ 완료)
- `extract_time_range_generic()`: 자연어 시간 범위 파싱 추가
- 제목 추출 로직 개선: 원본 메시지에서 직접 추출
- `calendar_approval` 이후 `calendar_confirm` 찾기 로직 개선

#### 단계 2: skill-calendar 토큰 갱신 로직 확인
- `skill-email/services/gmail_service.py` 패턴 참고
- `skill-calendar/services/google_calendar_service.py`에 동일한 로직 적용
- 토큰 갱신 실패 시 사용자에게 재인증 안내

### 3.3 리팩터링 (Refactor Phase)
- 중복 코드 제거
- 에러 처리 개선
- 로깅 개선

---

## 4. 기존 코드 확인

### 4.1 잘 되던 것 확인

#### skill-email 토큰 갱신 패턴 (참고)
```python
# skill_email/services/gmail_service.py:64-75
if creds and hasattr(creds, 'expired') and creds.expired and creds.refresh_token:
    logger.info(f"Refreshing expired token for user: {user_id}")
    from google.auth.transport.requests import Request
    creds.refresh(Request())
    
    # 갱신된 토큰 저장
    if hasattr(self.creds_provider, 'save_credentials'):
        self.creds_provider.save_credentials(user_id, creds)
```

#### skill-calendar 현재 상태
- `get_credentials()`: 토큰 로드만 수행
- `create_event()`: 토큰 갱신 없이 바로 API 호출
- ❌ **문제**: 만료된 토큰으로 API 호출 시 `invalid_grant` 에러

### 4.2 중복/충돌 확인

#### 중복 코드
- `skill-email`과 `skill-calendar`의 토큰 갱신 로직이 중복될 수 있음
- 공통 모듈로 분리 고려 (선택사항)

#### 충돌 없음
- rb8001 파싱 로직과 skill-calendar API는 독립적
- 문제는 skill-calendar의 OAuth 토큰 관리만 해당

---

## 5. 해결 방안

### 5.1 즉시 해결 (우선순위 1)

#### skill-calendar 토큰 갱신 로직 추가
```python
# skill-calendar/services/google_calendar_service.py
async def get_credentials(self, user_id: str) -> Optional[Credentials]:
    creds = await self._load_credentials_from_db(user_id)
    
    if not creds:
        return None
    
    # 토큰 갱신 (skill-email 패턴 참고)
    try:
        if creds and hasattr(creds, 'expired') and creds.expired and creds.refresh_token:
            logger.info(f"Refreshing expired token for user {user_id}")
            from google.auth.transport.requests import Request
            creds.refresh(Request())
            
            # 갱신된 토큰 저장 (DB 업데이트)
            await self._save_credentials_to_db(user_id, creds)
            logger.info(f"Refreshed token saved for user {user_id}")
    except Exception as e:
        logger.warning(f"Token refresh failed for user {user_id}: {e}")
        # Refresh 실패해도 계속 진행 (401 에러가 나면 그때 처리)
    
    return creds
```

### 5.2 사용자 안내 개선 (우선순위 2)

#### 에러 메시지 개선
```python
# rb8001/app/router/calendar_handler.py
if not result:
    logger.error(f"[Calendar] Event creation failed - OAuth token may be expired")
    return {
        "success": False,
        "message": "일정 등록에 실패했습니다. Google 캘린더 연동을 다시 확인해 주세요."
    }
```

### 5.3 장기 개선 (우선순위 3)

#### OAuth 재인증 플로우
- 토큰 갱신 실패 시 사용자에게 재인증 안내
- auth-server의 OAuth 재인증 URL 생성 API 활용

---

## 6. 테스트 실행 계획

### 6.1 단위 테스트
```bash
cd /home/admin/ivada_project/rb8001
docker exec -it rb8001 python3 tests/test_calendar_natural_language_parsing.py
```

### 6.2 통합 테스트
```bash
# 1. rb8001에서 일정 등록 요청
# 2. skill-calendar API 호출 확인
# 3. Google Calendar API 호출 확인
```

### 6.3 E2E 테스트
```bash
# 실제 사용자 시나리오 테스트
# "11월 24일 월요일 오후 1시부터 4시까지 컴퍼니엑스 세미나" 입력
# "ㅇㅇ" 승인
# Google Calendar에 일정 등록 확인
```

---

## 7. 참고 문서

- `DOCS/journey/plans/251114_skill_calendar_multiplatform_integration.md`: 캘린더 통합 계획
- `DOCS/journey/troubleshooting/250828_gmail_token_auto_refresh_RESOLVED.md`: Gmail 토큰 갱신 해결 사례
- `skill_email/services/gmail_service.py`: 토큰 갱신 패턴 참고
- `rb8001/tests/test_calendar_query_vs_create.py`: 기존 캘린더 테스트

---

## 8. 해결 완료 (2025-11-22)

### 8.1 구현 완료
1. ✅ rb8001 자연어 시간 범위 파싱 개선 (`extract_time_range_generic`)
2. ✅ rb8001 제목 추출 로직 개선 (원본 메시지에서 직접 추출)
3. ✅ skill-calendar 토큰 자동 갱신 로직 추가 (`get_credentials()`)
4. ✅ skill-calendar `_save_credentials_to_db()` 메서드 구현
5. ✅ skill-calendar `invalid_grant` 에러를 401로 전파
6. ✅ rb8001 401 에러 감지 및 재인증 안내 메시지

### 8.2 검증 완료 (2025-11-22 11:52)
```
사용자: "11월 24일 월요일 오후 1시부터 4시까지 컴퍼니엑스 세미나"
로빙: "네, 다음 주 월요일 11월 24일 오후 1시부터 4시까지 컴퍼니엑스 세미나 일정을 등록해 드릴게요. 구글 캘린더에 등록해드릴까요?"
사용자: "ㅇㅇ"
로빙: "✅ 구글 캘린더에 일정을 등록했습니다!
      제목: 컴퍼니엑스 세미나
      일시: 2025-11-24 13:00 ~ 16:00
      장소: N/A
      캘린더에서 보기"
```
✅ **성공**: 일정 등록 완료

### 8.3 수정된 파일
- `rb8001/app/router/calendar_handler.py`: 자연어 시간 파싱, 제목 추출, 재인증 안내
- `rb8001/app/skills/calendar_skill.py`: 401 에러 감지 및 전파
- `skill-calendar/services/google_calendar_service.py`: 토큰 자동 갱신, DB 저장
- `skill-calendar/routers/calendar.py`: `invalid_grant` → 401 변환

### 8.4 교훈
1. **자연어 파싱**: "오후 1시부터 4시까지" 같은 자연어 시간 표현을 정규식으로 파싱 가능
2. **제목 추출**: LLM 응답이 구조화되지 않아도 원본 메시지에서 직접 추출 가능
3. **OAuth 토큰 관리**: skill-email 패턴을 참고하여 skill-calendar에도 동일한 토큰 갱신 로직 적용
4. **에러 전파**: `invalid_grant` 같은 인증 에러는 401로 명확히 전파하여 상위 레이어에서 처리 가능
5. **사용자 안내**: 기술적 에러를 사용자 친화적인 재인증 안내 메시지로 변환

