# InsuWiki - 하이브리드 RAG 아키텍처 구현 스펙 v2.0

> **이 문서의 목적**: Claude Code가 InsuWiki 프로젝트에 RAG 기능을 구현하기 위한 완전한 기술 명세서입니다.
> 모든 아키텍처 결정은 확정되었으며, 이 문서를 기준으로 코드를 작성합니다.

---

## 1. 프로젝트 컨텍스트

- **프레임워크**: Next.js (Vercel 호스팅)
- **데이터베이스**: Firebase Firestore (Blaze 플랜 필수 - Cloud Functions 외부 API 호출을 위해)
- **원본 파일 저장소**: Google Drive
- **AI 엔진**: Google Gemini 1.5 Pro API
- **주요 도메인**: 보험 약관, 소식지, 보험료 테이블, 위키 본문, 유튜브 채널 정보

---

## 2. 정보 권위 계층 (Source Authority Hierarchy) - 시스템 최우선 원칙

> **이 계층이 모든 답변 생성의 기준입니다. 코드 어디에서도 이 순서를 역전시키지 않습니다.**

```
[1순위] 약관 원문 (PDF)        ← 법적 효력, 최고 권위
[2순위] 보험사 공식 소식지      ← 공식 안내, 개정 예고 포함
[3순위] InsuWiki 위키 본문      ← 내부 검증된 지식
[4순위] 유튜브 채널 정보        ← 참고 자료, 단독 인용 금지
```

**상충 시 처리 규칙:**
- 유튜브 내용이 약관 원문과 다를 경우 → 약관 원문 기준 답변 + "유튜브 영상과 다를 수 있습니다" 경고 명시
- 유튜브 내용이 소식지와 다를 경우 → 소식지 기준 답변
- 유튜브 영상 시점과 현재 약관 시점이 다를 경우 → 영상 업로드 시점에 유효했던 약관 기준으로 분석
- 상충 사실 자체를 사용자에게 반드시 명시적으로 고지

---

## 3. Google Drive 폴더 구조 및 파일명 규칙

### 3-1. 폴더 구조
```
📁 InsuWiki_RAG/                          ← 루트 (이 폴더 ID만 .env에 등록)
│
├── 📁 01_약관/                            ← 회사별 계층 구조
│   ├── 📁 생명보험/
│   │   ├── 📁 삼성생명/
│   │   │   ├── 삼성생명_퍼펙트종신_2403.pdf
│   │   │   └── 삼성생명_ABC종신_2512.pdf
│   │   ├── 📁 한화생명/
│   │   └── 📁 교보생명/
│   └── 📁 손해보험/
│       ├── 📁 현대해상/
│       └── 📁 DB손보/
│
├── 📁 02_소식지/                          ← 연도/월별 계층 구조
│   ├── 📁 2024년/
│   │   ├── 📁 01월/
│   │   │   ├── 삼성생명_소식지_2401.pdf
│   │   │   └── 현대해상_소식지_2401.pdf
│   │   └── 📁 02월/
│   └── 📁 2025년/
│
├── 📁 03_보험료비교/                       ← flat 구조, 그때그때 파일 추가
│   ├── 3대질병진단비_보험료비교_260203.pdf  ← 담보 유형별 여러 회사 비교표
│   └── 암진단비_보험료비교_260115.pdf      ← 매월 업데이트, 파일명에 날짜 포함
│
└── 📁 04_유튜브/                          ← Cloud Functions 자동 생성
    ├── 📁 보험명의정닥터/
    │   ├── 20260203_영상제목_요약.txt
    │   └── 20260203_영상제목2_요약.txt
    └── 📁 다른채널명/
```

### 3-2. 파일명 규칙 (폴더별 상이)

```
[01_약관]
✅ {회사명}_{상품명}_{YYMM}.pdf
   예) 삼성생명_퍼펙트종신_2403.pdf
       현대해상_운전자보험_2501.pdf

[02_소식지]
✅ {회사명}_소식지_{YYMM}.pdf
   예) 삼성생명_소식지_2502.pdf
       현대해상_소식지_2502.pdf

[03_보험료비교]
✅ {담보유형명}_보험료비교_{YYMMDD}.pdf
   예) 3대질병진단비_보험료비교_260203.pdf
       암진단비_보험료비교_260115.pdf
   ※ 날짜는 YYMMDD (일 단위) - 같은 달에 여러 버전 존재 가능

[04_유튜브]
✅ {YYYYMMDD}_{영상제목}_요약.txt
   예) 20260203_345보험치명적함정_요약.txt
   ※ Cloud Functions가 자동 생성, 수동 작성 금지

❌ 모든 폴더 공통 금지: 수정본, 최종, 진짜최종 등 버전 표기
```

폴더 경로가 곧 메타데이터입니다. Cloud Function이 경로를 파싱하여 sourceType, category 등을 자동 추출합니다.

```
01_약관/생명보험/{회사명}/  → sourceType: "policy", category: "life", companyId 자동 추출
01_약관/손해보험/{회사명}/  → sourceType: "policy", category: "non_life", companyId 자동 추출
02_소식지/{연도}/{월}/      → sourceType: "newsletter", effectiveDate 자동 추출
03_보험료비교/              → sourceType: "premium_table", 파일명에서 날짜 파싱
04_유튜브/{채널명}/         → sourceType: "youtube", channelId 매핑
```

---

## 4. 데이터 유형별 아키텍처

데이터를 **4가지 유형**으로 분리하여 각각 다른 방식으로 처리합니다.

### 4-1. 위키 본문 (마크다운 텍스트)
```
저장: Firestore 기존 컬렉션
검색: Firestore Vector Search
방식: 의미 기반 RAG
비용: ~$0
```

### 4-2. 보험료 비교 테이블 (03_보험료비교)
```
원본 보관: Google Drive 03_보험료비교/ (flat 구조)
파싱 엔진: Gemini Vision 2.5-flash + File API
           responseMimeType: "application/json" 강제화 (JSON 출력 안정성 확보)
저장: Firestore insurance_tables 컬렉션 (구조화 데이터)
검색: 조건 쿼리 (담보유형/회사/나이/성별 등 정확한 필터링)
방식: NoSQL 키-값 조건 쿼리 (RAG 사용 금지 - 1원 오차도 허용 불가)
비용: $0

파이프라인:
  Drive 03_보험료비교/ 파일 업로드
         ↓
  Cloud Functions (페이지 → 이미지 변환)
         ↓
  Gemini Vision 2.5-flash + File API
  (responseMimeType: "application/json" 강제)
         ↓
  JSON 파싱 → 회사별 행 분리
         ↓
  Firestore insurance_tables 적재
  (하나의 파일에서 여러 companyId 데이터 생성)

✅ Phase 0 완료 - 자동화(Plan A) 확정
```

### 4-3. 약관 PDF / 소식지
```
저장: Google Drive (원본 보관)
검색: 메타데이터 인덱스 선별 → Gemini 롱 컨텍스트 직접 처리
방식: 비동기 Cloud Functions + Gemini File API + Firestore 캐싱
비용: Gemini 1.5 Pro 무료 티어 (128K 이하 시 ~$0, 1일 50회 한도)
```

### 4-4. 유튜브 채널 정보
```
원본 보관: Google Drive 04_유튜브/{채널명}/ (요약 txt 파일, Cloud Functions 자동 생성)
검색용: Firestore youtube_knowledge 컬렉션 (임베딩 벡터 + 메타데이터)
방식: 6시간/24시간 주기 크롤링 → 자막 추출 → 요약 → Drive 저장 + Firestore 인덱싱
      약관과 상충 감지 후 conflictsWithPolicy 플래그 저장
권위: 4순위, 단독 인용 금지
비용: YouTube Data API (무료 티어 내)

Drive = 요약 원본 보관소
Firestore = 검색 인덱스 (Drive 파일 경로 참조)
```

---

## 5. 질의 유형 분류기 (Query Router)

> 설계사 질의는 4가지 유형으로 분류되며, 각각 완전히 다른 처리 경로를 따릅니다.

### 5-1. 질의 유형 분류표

| 유형 | 예시 질문 | 처리 방식 |
|------|----------|----------|
| **A. 수치 조회형** | "40세 남성 삼성생명 종신보험 보험료?" | Firestore 구조화 쿼리 |
| **B. 횡단 검색형** | "뇌혈관 보장 가장 넓은 회사 찾아줘" | Vector RAG (전체 문서) |
| **C. 심층 분석형** | "삼성생명 이 특약 면책기간 정확히?" | Long-Context 단일 PDF |
| **D. 복합형** | "40대 비흡연 예산 15만원, 삼성·한화 암+뇌혈관 보험료 비교" | 질문 분해 → 순차 처리 |

### 5-2. 라우팅 흐름도

```
설계사 질문 입력
       ↓
[용어 정규화] - insurance_terms에서 동의어 확장
       ↓
[Query Router - 규칙 기반 분류]
       │
       ├─── A형 (수치 조회)
       │    → 필수 파라미터 확인 (나이/성별/흡연/납입기간/가입금액)
       │    → 부족 시 강제 수집 역질문
       │    → Firestore 조건 쿼리 → 즉시 응답
       │
       ├─── B형 (횡단 검색)
       │    → 비교 기준 확인 (없으면 역질문)
       │    → Vector Search TOP 5 → Gemini 답변
       │    → 복수 출처 표시
       │
       ├─── C형 (심층 분석)
       │    → 회사/상품 선택 강제 (Disambiguation)
       │    → Cloud Functions 비동기 처리
       │    → 단일 PDF Long-Context
       │
       ├─── D형 (복합)
       │    → 질문을 A/B/C 단위로 분해
       │    → 각각 순차 처리 후 통합 답변
       │
       └─── AMBIGUOUS
            → UX에서 사용자 직접 선택 유도
```

### 5-3. 규칙 기반 분류 코드

```typescript
function classifyQuery(question: string, context: QueryContext): QueryType {
  const numericKeywords = ["보험료", "월납", "연납", "해지환급금", "환급률", "얼마"];
  const crossKeywords   = ["가장", "비교", "어느 회사", "추천", "찾아줘", "어디가"];
  const multiKeywords   = ["그리고", "또한", "같이", "동시에"];

  const hasNumeric = numericKeywords.some(k => question.includes(k));
  const hasCross   = crossKeywords.some(k => question.includes(k));
  const hasMulti   = multiKeywords.some(k => question.includes(k));

  if ((hasNumeric && hasCross) || hasMulti) return "COMPLEX";
  if (hasNumeric && context.companyId)      return "TABLE_QUERY";
  if (hasCross)                             return "VECTOR_SEARCH";
  if (context.companyId && context.productId) return "DEEP_QUERY";

  return "AMBIGUOUS";
}
```

---

## 6. Disambiguation (대화형 조건 명확화)

> 매칭 불확실 또는 파라미터 부족 시 추정 답변 대신 반드시 대화로 조건을 좁힙니다.
> **역질문은 1회 최대 2개로 제한합니다.**

### 6-1. Disambiguation이 필요한 전체 케이스

| 케이스 | 상황 | 시스템 반응 |
|--------|------|------------|
| 담보명 중복 | 여러 상품에 동일/유사 담보명 | 상품 목록 제시 → 선택 요구 |
| 개정 이력 존재 | 동일 상품 버전 여러 개 | 버전 목록 + 개정 사실 경고 → 선택 요구 |
| 회사 미특정 | 비교 질문에 기준 없음 | 비교 기준 먼저 확정 |
| 고객 조건 미입력 | 보험료 조회 시 조건 없음 | 필수 파라미터 강제 수집 |
| 청구 조건 질문 | 보장범위/지급조건/중복청구 혼재 | 의도 분류 후 처리 |
| 용어 동음이의 | "해지" = 환급금 or 절차 | 의미 선택 유도 |
| 약관 DB 없음 | 해당 상품 파일 미등록 | "해당 약관을 DB에 등록해주세요" 안내 + 재요청 유도 |
| 판매 중단 상품 | 판매는 종료됐으나 유지 중 | 조회 허용 + "판매 중단 상품" 배지 표시 |

### 6-2. 담보명 중복 대화 예시

```
설계사: "삼성생명 가나다 담보 보장범위가 어떻게 돼?"

시스템: "삼성생명 상품 중 '가나다' 관련 담보가 여러 상품에서 발견됐습니다.
         어떤 상품 기준으로 확인할까요?

         ① 퍼펙트종신 (2024.03) - 가나다 담보
         ② 일반종신   (2024.03) - 가나다 담보
         ③ ABC종신    (2025.12) - 가나다II 담보 [개정판]"

설계사: "①"

시스템: [퍼펙트종신 PDF Long-Context 분석 → 정확한 원문 인용 답변]
        ※ 출처: 퍼펙트종신 약관 23p
```

---

## 7. 면책기간 날짜 계산

설계사가 고객의 청구 가능 여부를 실시간으로 확인할 수 있어야 합니다.

```typescript
interface ExemptionPeriodQuery {
  companyId: string;
  productId: string;
  coverageName: string;       // 담보명
  contractDate: string;       // 고객 계약일 "2023-06-15"
  queryDate?: string;         // 확인 기준일 (기본값: 오늘)
}

// 처리 흐름:
// 1. 약관에서 해당 담보의 면책기간 추출 (예: "계약일로부터 90일")
// 2. contractDate + 면책기간 = 보장 시작일 계산
// 3. 보장 시작일 vs queryDate 비교
// 4. 결과: "보장 가능 (면책기간 경과)" or "보장 불가 (D-{n}일 남음)"
// ⚠️ 반드시 법적 면책 문구 병기
```

---

## 8. 환각 방지 3중 구조

> **보험 도메인에서 환각은 설계사가 고객에게 잘못된 정보를 전달하는 사고로 직결됩니다.**
> 모르면 모른다고 해야 합니다.

### 8-1. 유사도 임계값 게이트

```
유사도 0.85 이상  → 신뢰할 수 있는 매칭 → 답변 생성 허용
유사도 0.70~0.85  → 불확실 → Disambiguation으로 범위 좁히기
유사도 0.70 미만  → 매칭 없음 선언 → 추정 답변 생성 절대 금지
                    → "해당 내용을 찾을 수 없습니다" 반환
```

### 8-2. Gemini 시스템 프롬프트 (절대 변경 금지)

```
당신은 보험 약관 분석 전문가입니다.
아래 규칙을 반드시 따르세요:

1. 제공된 약관 원문에 명시된 내용만 답변합니다.
2. 원문에 없는 내용은 절대 추정하거나 생성하지 않습니다.
3. 확인 불가 시 반드시 다음 형식으로 답변합니다:
   "제공된 약관에서 해당 내용을 확인할 수 없습니다.
    [어떤 정보가 부족한지 구체적으로 설명]"
4. 유사한 내용이 있어도 정확히 일치하지 않으면 언급하지 않습니다.
5. 답변 마지막에 반드시 출처를 명시합니다.
   형식: "※ 출처: {상품명} 약관 {페이지}p"
6. "아마도", "추정", "일반적으로", "보통은", "대개", "~인 것 같"
   표현을 절대 사용하지 않습니다.
```

### 8-3. 답변 자동 검증 필터

```typescript
function validateAnswer(answer: string): ValidationResult {
  const uncertainPhrases = ["아마도", "추정", "일반적으로", "보통은", "대개", "인 것 같"];
  if (uncertainPhrases.some(p => answer.includes(p)))
    return { valid: false, reason: "불확실 표현 감지" };
  if (!answer.includes("※ 출처:"))
    return { valid: false, reason: "출처 누락" };
  return { valid: true };
}
// 검증 실패 시 → 재처리 or "확인 불가" 반환 (그대로 출력 금지)
```

### 8-4. 답변 불가 케이스 완전 목록

| 상황 | 시스템 반응 |
|------|------------|
| 유사도 0.70 미만 | "해당 내용을 약관에서 찾을 수 없습니다" |
| 여러 상품 동일 담보명 | 상품 목록 → 선택 요구 |
| 개정 이력 존재 | 버전 목록 → 선택 요구 |
| 고객 조건 미입력 (보험료) | 조건 입력 폼 강제 |
| 약관 범위 밖 질문 | "약관 범위 밖의 질문입니다" |
| 불확실 표현 포함 답변 | 자동 폐기 → 재처리 or 확인 불가 |
| 약관 DB 미등록 상품 | "해당 약관을 DB에 등록해주세요" 안내 |

---

## 9. 법적 면책 문구 (모든 답변 자동 삽입)

```
[약관 원문 기반 답변]
※ 출처: {상품명} 약관 {페이지}p
※ 이 답변은 약관 원문 검색 결과이며, 실제 보험금 지급 여부는
   보험사 심사 기준에 따릅니다. 청구 가능 여부는 반드시 보험사에 확인하세요.

[소식지 기반 답변]
※ 출처: {회사명} {연월} 소식지
※ 주의: 소식지는 요약본입니다. 정확한 내용은 약관 원문을 확인하세요.

[유튜브 기반 답변] ← 단독 인용 금지, 반드시 약관과 함께
※ 참고: {채널명} ({영상 업로드일}) - 참고 자료 수준
※ 실제 약관 내용과 다를 수 있습니다. 약관 원문을 우선하세요.

[유튜브 ↔ 약관 상충 시]
⚠️ 주의: 유튜브 영상 내용과 약관 원문이 다릅니다.
   영상 내용: "..."
   약관 기준: "..." ← 이것이 정확한 내용입니다.
```

---

## 10. 대화 세션 관리

```typescript
interface ConversationSession {
  sessionId: string;
  agentId: string;
  context: {
    lastCompanyId?: string;
    lastProductId?: string;
    lastCoverageId?: string;
    lastQueryType?: QueryType;
    resolvedParams?: Record<string, string>; // 이미 확인된 파라미터
  };
  history: Message[];
  createdAt: Timestamp;
  expireAt: Timestamp;  // 30분 비활성 시 자동 종료
}

// 연속 질문 처리 예시:
// 설계사: "삼성생명 퍼펙트종신 가나다 담보 보장범위?"  → context에 회사/상품 저장
// 설계사: "그럼 나나나 담보는?"  → context 활용, 회사/상품 재질문 불필요
```

---

## 11. 용어 자동 추출 파이프라인

### 11-1. 인덱싱 시 자동 추출

```typescript
const TERM_EXTRACTION_PROMPT = `
이 약관에서 보험 용어를 추출해주세요.
특히 다음을 찾아주세요:
1. 이 상품 고유의 담보/특약 명칭
2. 질병 분류 기준 (어떤 코드/정의를 쓰는지)
3. 일반 용어와 다르게 정의된 용어

JSON 배열로만 출력 (다른 텍스트 금지):
[{
  "term": "뇌혈관질환",
  "definition": "약관 원문 정의 그대로",
  "commonAliases": ["뇌졸중", "중풍"],
  "icdCodes": ["I60", "I61", "I63"],
  "pageNumber": 12
}]
`;
// 추출된 용어 → insurance_terms 컬렉션에 verified: false로 저장
// → 종혁님 검수 후 verified: true로 변경
// → 미검증 항목은 검색에 사용하되 답변에 "(미검증)" 경고 표시
```

### 11-2. 검색 전 용어 정규화

```
질문 입력
   ↓
insurance_terms 조회: "뇌졸중" → {"뇌혈관질환", "뇌졸중", "뇌혈관장해", "허혈성뇌졸중"} 확장
   ↓
확장된 키워드로 Vector Search
→ 삼성생명(뇌혈관질환), 현대해상(뇌혈관장해) 모두 검색에 걸림
```

---

## 12. 유튜브 수집 파이프라인

### 12-1. 수집 구조

```
[youtube_channels 컬렉션에 등록된 채널 목록]
       ↓
[Cloud Functions Scheduled Job - 매일 1회]
YouTube Data API로 신규 영상 감지
       ↓
자막/스크립트 추출 (YouTube Caption API)
       ↓
텍스트 청킹 + Gemini 임베딩
       ↓
약관 원문과 상충 내용 자동 감지 → conflictsWithPolicy 플래그 저장
       ↓
youtube_knowledge 컬렉션 저장 (sourceType: "youtube", 4순위 권위)
```

### 12-2. 유튜브 정보 답변 규칙

```
✅ 허용: 약관 답변 보조 자료로 함께 제시
✅ 허용: "이 영상에서 이렇게 설명하나, 약관 기준은 다음과 같습니다"
❌ 금지: 유튜브 정보만 단독으로 인용한 답변
❌ 금지: 유튜브 내용을 약관보다 우선하는 것
❌ 금지: 상충 사실을 숨기고 한쪽만 답변하는 것
```

---

## 13. Firestore 컬렉션 전체 스키마

#### `jobs`
```typescript
interface Job {
  jobId: string;
  status: "pending" | "complete" | "failed";
  queryType: "TABLE_QUERY" | "VECTOR_SEARCH" | "DEEP_QUERY" | "COMPLEX";
  question: string;
  companyId?: string;
  productId?: string;
  answer?: string;
  citations?: string[];
  error?: string;
  createdAt: Timestamp;
  expireAt: Timestamp;   // createdAt + 10분 (TTL 자동 삭제)
}
```

#### `gemini_file_cache`
```typescript
interface FileCache {
  fileKey: string;           // "{companyId}_{productId}_{fileHash}"
  fileId: string;            // Gemini File API file_id
  driveFileId: string;
  driveModifiedAt: Timestamp;
  uploadedAt: Timestamp;
  expireAt: Timestamp;       // uploadedAt + 48시간
}
// 캐시 무효화: Drive 파일 수정 시 해당 문서 즉시 삭제
```

#### `insurance_metadata`
```typescript
interface InsuranceMetadata {
  companyId: string;
  companyName: string;
  productId: string;
  productName: string;
  category: "life" | "non_life" | "variable";
  driveFileId: string;
  driveFileName: string;
  effectiveDateRange: { start: string; end?: string }; // "2024-03"
  isSalesStopped: boolean;   // 판매 중단 여부 (조회는 계속 허용)
  pageCount?: number;
  updatedAt: Timestamp;
}
```

#### `insurance_chunks` (B형 Vector RAG용)
```typescript
interface InsuranceChunk {
  chunkId: string;
  companyId: string;
  productId: string;
  pageNumber: number;
  chunkText: string;
  embedding: number[];       // Gemini Text Embedding 768차원
  coverageNames: string[];   // 담보명 태깅 (Disambiguation용)
  sourceType: "policy" | "newsletter" | "wiki";
  effectiveDate: string;
  driveFileId: string;
  createdAt: Timestamp;
}
// ⚠️ 보험료/해지환급금 표 내용은 이 컬렉션에 절대 넣지 않음
```

#### `insurance_tables`
```typescript
interface InsuranceTable {
  companyId: string;
  productId: string;
  age: number;
  gender: "M" | "F";
  smokingStatus: "smoker" | "non_smoker";
  paymentPeriod: number;
  coverageAmount: number;
  premium: number;           // 1원 단위 정확
  tableType: "premium" | "surrender_value";
  effectiveDate: string;
  sourceNotes?: string;
}
```

#### `insurance_terms`
```typescript
interface InsuranceTerm {
  term: string;
  definition: string;
  commonAliases: string[];
  icdCodes?: string[];
  companyId: string;
  productId: string;
  pageNumber: number;
  verified: boolean;         // 종혁님 검수 완료 여부
  createdAt: Timestamp;
}
```

#### `query_logs` (감사 로그)
```typescript
interface QueryLog {
  logId: string;
  agentId: string;
  sessionId: string;
  question: string;
  queryType: QueryType;
  sourceDocs: string[];
  answer: string;
  confidenceScore: number;
  hasDisclaimerAttached: boolean;
  feedbackStatus?: "correct" | "incorrect" | "incomplete";
  timestamp: Timestamp;
}
```

#### `conversation_sessions`
```typescript
interface ConversationSession {
  sessionId: string;
  agentId: string;
  context: {
    lastCompanyId?: string;
    lastProductId?: string;
    lastCoverageId?: string;
    lastQueryType?: QueryType;
    resolvedParams?: Record<string, string>;
  };
  history: Message[];
  createdAt: Timestamp;
  expireAt: Timestamp;       // 30분 비활성 시 종료
}
```

#### `youtube_channels`
```typescript
interface YoutubeChannel {
  channelId: string;
  channelName: string;
  isActive: boolean;
  lastCrawledAt: Timestamp;
}
```

#### `youtube_knowledge`
```typescript
interface YoutubeKnowledge {
  videoId: string;
  channelId: string;
  channelName: string;
  title: string;
  publishedAt: Timestamp;    // 영상 업로드 시점 (약관 시점 비교용)
  chunkText: string;
  embedding: number[];
  relatedCompanyIds: string[];
  relatedProductIds: string[];
  conflictsWithPolicy: boolean;
  conflictDetail?: string;
  sourceType: "youtube";
  createdAt: Timestamp;
}
```

---

## 14. Cloud Functions 구현 스펙

### 14-1. 함수 목록

```typescript
// C형 Deep Query (타임아웃 9분)
export const processDeepQuery = onDocumentCreated(
  { document: "jobs/{jobId}", timeoutSeconds: 540, memory: "1GiB" },
  async (event) => { ... }
);

// 유튜브 신규 영상 수집 (매일 1회)
export const crawlYoutubeChannels = onSchedule(
  { schedule: "every 24 hours" },
  async () => { ... }
);

// 신규 PDF 업로드 감지 → 자동 인덱싱
export const onNewPdfUploaded = onObjectFinalized(
  { bucket: "insuwiki-drive-sync" },
  async (event) => { ... }
);
```

### 14-2. 에러 처리 (좀비 Job 방지)

```typescript
try {
  const result = await processQuery(job);
  await updateJob(jobId, { status: "complete", answer: result.answer });
} catch (error) {
  // 어떤 에러든 반드시 failed 기록
  await updateJob(jobId, { status: "failed", error: error.message });
}
// Job 문서 TTL 10분으로 고아 Job 자동 소멸
```

### 14-3. 동시 사용자 큐 구조 (확장 대응)

```
Deep Query 요청 → jobs 컬렉션에 pending 적재
                   ↓
Cloud Functions maxInstances: 10 설정
                   ↓
Gemini API Rate Limit 초과 시:
  → exponential backoff 재시도 (최대 3회)
  → 3회 실패 시 status: "failed" 저장
```

---

## 15. 프론트엔드 구현 스펙

### 15-1. Polling 구현

```typescript
const MAX_WAIT_MS   = 3 * 60 * 1000; // 3분
const POLL_INTERVAL = 4000;           // 4초

async function pollJobStatus(jobId: string) {
  const startTime = Date.now();
  const interval = setInterval(async () => {
    if (Date.now() - startTime > MAX_WAIT_MS) {
      clearInterval(interval);
      showErrorModal("서버 응답 지연. 잠시 후 다시 시도해 주세요.");
      return;
    }
    const job = await fetch(`/api/ai/status?jobId=${jobId}`).then(r => r.json());
    if (job.status === "complete") { clearInterval(interval); renderAnswer(job); }
    if (job.status === "failed")   { clearInterval(interval); showErrorModal(job.error); }
  }, POLL_INTERVAL);
  return () => clearInterval(interval);
}
```

### 15-2. 필수 UI 요소

- Skeleton UI (답변 대기 중)
- 판매 중단 상품 배지 ("판매 종료 상품 - 기존 가입자 조회용")
- 출처 배지 (약관 / 소식지 / 유튜브 색상 구분)
- 유튜브-약관 상충 경고 배너
- 에러 모달 (failed 상태)
- 답변 하단 면책 문구 (자동 삽입, 삭제 불가)
- 피드백 버튼 (맞음 / 틀림 / 불완전)
- "최근 편집 문서는 AI 반영까지 최대 48시간 소요" 안내

---

## 16. Phase 0: 보험료 비교 테이블 파싱 PoC ✅ 완료

### 16-1. 테스트 결과

| 테스트 파일 | 결과 |
|------------|------|
| `3대질병진단비 보험료(26.02.03).pdf` | ✅ 수치 오류율 0% 통과 |
| `상품비교 가이드북 (생손보 02.03).pdf` | ✅ 수치 오류율 0% 통과 (생손보 혼합 복합 구조 - 사실상 최극악 케이스) |

### 16-2. 확정 사항

```
✅ Gemini Vision 2.5-flash + File API 조합 확정
✅ responseMimeType: "application/json" 강제화로 JSON 출력 안정성 확보
✅ Plan A (자동화) 확정
🗑️ Plan B (Google Sheets 수동 입력) 폐기
✅ insurance_tables 관련 코드 작성 착수 가능
```

---

## 17. 시스템 신뢰도 대시보드

```
추적 지표:
  - "찾을 수 없음" 응답 비율    (높으면 인덱스 문제)
  - 설계사 피드백 "틀림" 비율   (높으면 프롬프트 문제)
  - 평균 응답 시간
  - 유사도 임계값 미달 비율
  - 유튜브-약관 상충 감지 건수
  - Disambiguation 발생 비율
```

---

## 18. 비용 구조 요약

| 항목 | 플랜 | 예상 월 비용 |
|------|------|-------------|
| Firebase Firestore | Blaze (무료 구간 내) | ~$0 |
| Firebase Cloud Functions | Blaze (200만 회 무료) | ~$0 |
| Google Drive | 개인 100GB | ~₩2,000 |
| Gemini Embedding API | 초기 인덱싱 1회성 | ~$1~5 |
| Gemini 1.5 Pro API | 무료 티어 (1일 50회) | ~$0 |
| Gemini 1.5 Pro API (초과) | $3.5/1M tokens | 사용량 따라 |
| YouTube Data API | 무료 티어 | ~$0 |

**예상 총 비용: 월 ₩2,000~₩25,000**

---

## 19. 환경 변수 목록

```env
# Google
GOOGLE_SERVICE_ACCOUNT_KEY=...
GOOGLE_DRIVE_ROOT_FOLDER_ID=...
GEMINI_API_KEY=...               # 서버 전용, 클라이언트 노출 금지

# Firebase
FIREBASE_PROJECT_ID=...
FIREBASE_CLIENT_EMAIL=...
FIREBASE_PRIVATE_KEY=...

# YouTube
YOUTUBE_API_KEY=...

# Dify (위키 RAG용)
DIFY_API_KEY=...
DIFY_BASE_URL=...
```

---

## 20. 개발 순서 (권장)

```
Step 1: ✅ Phase 0 완료
  └ Gemini Vision 2.5-flash + File API 조합 확정
  └ insurance_tables 코드 착수 가능

Step 2: Firestore 컬렉션 + TTL + 벡터 인덱스 설정
  └ 전체 컬렉션 생성
  └ jobs TTL 10분 설정
  └ insurance_chunks.embedding 벡터 인덱스 설정

Step 3: 용어 추출 파이프라인
  └ PDF 인덱싱 시 insurance_terms 자동 생성
  └ 검수 워크플로우 구축 (verified 플래그 관리)

Step 4: Query Router 구현
  └ 용어 정규화 → 분류 → 유형별 라우팅
  └ Disambiguation 로직 (케이스별 역질문)
  └ 복합 질문 분해 처리

Step 5: Cloud Functions 구현 (C형 Deep Query)
  └ 캐시 조회 → Drive 다운로드 → Gemini 업로드 → 답변 검증 → 저장
  └ 감사 로그 저장
  └ 에러 시 반드시 failed 기록

Step 6: Vector 인덱싱 파이프라인 (B형)
  └ 청킹 → 임베딩 → insurance_chunks 저장
  └ 표 내용 제외 확인

Step 7: Next.js API Routes
  └ POST /api/ai/query  → Router → 유형별 처리
  └ GET  /api/ai/status → Polling용
  └ POST /api/ai/feedback → 설계사 피드백

Step 8: 프론트엔드
  └ Disambiguation UX
  └ Polling (4초, 3분 타임아웃)
  └ 면책 문구 자동 삽입
  └ 출처 배지 / 상충 경고 / 피드백 버튼

Step 9: 유튜브 파이프라인
  └ 채널 등록 → 크롤링 → 자막 추출 → 임베딩
  └ 약관 상충 감지 로직

Step 10: 캐시 무효화 + 신뢰도 대시보드
  └ Drive 파일 수정 감지 → 캐시 삭제 + 청크 재인덱싱
  └ 신뢰도 지표 대시보드 구축
```

---

## 21. 금지 사항 (절대 하지 말 것)

```
❌ Gemini API Key를 프론트엔드 코드에 포함
❌ 브라우저에서 Gemini File API 직접 호출
❌ onSnapshot으로 job 상태 감시 (Polling 사용)
❌ 보험료/해지환급금 수치를 RAG로 답변
❌ 보험료/해지환급금 표 내용을 insurance_chunks에 저장
❌ 유사도 0.70 미만 결과로 답변 생성
❌ 불확실 표현("아마도/추정/일반적으로") 포함 답변 출력
❌ 출처 없는 답변 출력
❌ 면책 문구 없는 답변 출력
❌ 유튜브 정보를 약관 원문과 동등하게 취급
❌ 유튜브 정보만 단독으로 인용한 답변
❌ 유튜브-약관 상충 사실을 숨기고 한쪽만 답변
❌ 판매 중단 상품 약관 조회 차단
❌ 복합 질문을 분해하지 않고 단일 유형으로 처리
❌ Vercel 서버에서 대용량 PDF 직접 스트리밍
❌ Public 위키와 Private 노트를 동일 RAG 파이프라인에 혼재
```
