# task-3001 — 인슈위키 유튜브 → wiki 자동 게시 + 요약 규격화 + 전사 첨부 권한

- **팀**: dev2-team (오딘) · **레벨**: Lv.2 · **작성**: 2026-08-24
- **저장소**: `JonghyukJeon/InsuWiki` · 기준 브랜치 `origin/master` = `01b4360`
- **워크트리**: `/home/jay/.worktrees/task-3001-dev2` · 브랜치 `task/task-3001-dev2`
- **머지**: ★ 하지 않음. 태스크 명세대로 **PR 생성까지가 범위**이며 머지는 ANU 담당.

---

## S (상황)

유튜브 파이프라인은 `youtube_knowledge` / `insurance_chunks` / `youtube_channels` 에만 쓰고 있었고,
인슈위키 wiki 화면이 읽는 `documents` 컬렉션에는 아무것도 쓰지 않았다.
즉 **파이프라인 산출물이 wiki 메뉴에 나타날 경로 자체가 없었다.**
요약은 「핵심 논지 재구성」이 아니라 일반 요약(실측 973자)에 그쳤고,
전사 첨부는 Drive 링크 직행이라 인슈위키 인증과 무관했다.

## C (복잡성)

명세와 실측이 **3곳에서 달랐다.** 명세를 그대로 구현했으면 카드가 화면에 뜨지 않았다.

| # | 명세 기술 | 실측 | 영향 |
|---|---|---|---|
| 1 | wiki 목록은 `where('docType','==','wiki')` 로 조회 | **거짓.** `nextapp/src/app/page.tsx:124` 는 `where('visibility','==','public')` + `orderBy('updatedAt','desc')`. wiki 탭은 `docType` 을 **검사조차 하지 않음**(`page.tsx:77`) | 명세대로 `docType` 만 맞추고 `visibility` 를 빠뜨렸으면 **카드가 아무에게도 안 보임** |
| 2 | 기존 첨부 **6건** | **8개 문서 / 첨부 10건** (프로덕션 `documents` 489건 전수 조회) | 정합 기준 산정 오차 |
| 3 | `youtube_knowledge` 읽는 앱 코드 0건 | 사실상 맞음. `nextapp` 내 2건은 **타입·상수 정의뿐**(`types/firestore.ts:596,647`), 실소비 0 | 명세 결론 유지 |

추가로 `page.tsx:140` 이 `data.title.match()` 를 호출하므로 **`title` 이 비면 `fetchDocuments()` 전체가
예외로 무너져 목록이 통째로 안 뜬다.** 한 문서의 결손이 화면 전체를 죽이는 구조라 fail-closed 가 필요했다.

## Q (질문)

파이프라인이 어떤 필드를 채워야 실제 화면에 카드가 뜨는가? 재처리해도 카드가 1개로 유지되는가?
전사 첨부를 로그인 사용자에게만 열어줄 수 있는가?

## A (답변 / 조치)

### ① wiki 카드 자동 생성
`firestore_writer.save_wiki_document()` 신규. 문서 ID = **`youtube-{videoId}`** (결정적 ID) →
`.get()` 으로 존재 확인 후 `set(merge=True)`. `createdAt` 은 최초 1회만 기록해 갱신 시 보존.
`title` 이 비면 `ValueError` 로 저장 거부(위 목록 붕괴 방지). `is_title_only` 면 카드 미생성.
채우는 필드: `visibility='public'`(노출의 유일한 조건) · `docType='wiki'` · `title` · `content`(요약 전문, truncate 없음) ·
`updatedAt`/`createdAt`(SERVER_TIMESTAMP) · `isDeleted=False` · `category='practice'` · `authorId/authorName` ·
`attachments[]` · `searchKeywords` · `version` · `properties`.

★ `save_youtube_knowledge` / `save_insurance_chunk` 는 **한 줄도 건드리지 않았다**
(`firestore_writer.py` diff = **64 insertions / 0 deletions**, 순수 추가).

### ② 요약 규격 개정
`YOUTUBE_SUMMARY_PROMPT` 를 4단 구조(주제 한줄 → 핵심 주장 → 수치·조건·예외 → 실무 시사점) +
1,500~2,000자 「핵심 논지 재구성」 규격으로 개정. 기존 안전장치(자막 외 내용 금지 · 수치 원문 인용 ·
불확실 표현 금지 · 말미 고지문) 전부 유지. **하한 미달 시 보강 지시를 붙여 재시도하고, 소진되면 `RuntimeError`**
(조용한 통과 없음). 규격은 `docs/youtube-summary-guide.md` 에 박제.

### ③ 전사 첨부 로그인 한정
`GET /api/attachments/[fileId]` 인증 프록시 신설. 2단 방어:
(1) 기존 공용 헬퍼 `verifyMember`(`@/lib/auth-middleware`, 16개 라우트가 이미 사용)로 토큰 + `role≥member` 검증
— `firestore.rules:23-25` `isMemberOrAbove()` 와 동일 기준.
(2) 요청된 fileId 가 **실제로 어떤 `documents` 문서의 `attachments[]` 에 등재돼 있는지** 확인 후
그 문서의 조회 권한까지 검사 → 미등재 404 / 무권한 403. **로그인만으로 서비스 계정 Drive 전체를 읽는 구멍을 차단.**
(3) `fileId` 정규식 `^[A-Za-z0-9_-]{10,100}$` 로 경로 조작 차단.
프론트는 `url` 이 `/api/` 로 시작할 때만 인증 fetch→blob 경로를 타고, **기존 첨부 10건은 종전 동작 그대로**(하위호환).

---

## 수정 파일별 검증 상태

| 파일 | 변경 | 검증 방법 | 상태 |
|---|---|---|---|
| /home/jay/.worktrees/task-3001-dev2/scripts/youtube-pipeline/youtube_pipeline/firestore_writer.py | +64 / -0 | pytest 104 + 변이 3종 FAIL 실증 + 실파이프라인 2회 | verified |
| /home/jay/.worktrees/task-3001-dev2/scripts/youtube-pipeline/youtube_pipeline/summarizer.py | +150 / -? | pytest + 변이 2종 FAIL + 실제 Gemini 호출 2회 실측 | verified |
| /home/jay/.worktrees/task-3001-dev2/scripts/youtube-pipeline/youtube_pipeline/drive_uploader.py | 반환형 DriveFile 확장 | pytest + 실제 Drive 업로드 2회 + permissions().list 실측 | verified |
| /home/jay/.worktrees/task-3001-dev2/scripts/youtube-pipeline/youtube_pipeline/main.py | wiki 배선 | test_main_pipeline.py 신규 + 실행 2회 | verified |
| /home/jay/.worktrees/task-3001-dev2/scripts/youtube-pipeline/youtube_pipeline/config.py | 상수 7종 추가 | 변이(하한 0) FAIL 실증 | verified |
| /home/jay/.worktrees/task-3001-dev2/nextapp/src/app/api/attachments/[fileId]/route.ts | 신규 117줄 | vitest 10건 + 실서버 curl 401/400 | partial (아래 미실증 참조) |
| /home/jay/.worktrees/task-3001-dev2/nextapp/src/app/docs/[id]/DocumentClient.tsx | +44 / -3 | 실브라우저 클릭 재현 | partial |
| /home/jay/.worktrees/task-3001-dev2/docs/youtube-summary-guide.md | 신규 82줄 | 규격 대조 | verified |
| /home/jay/.worktrees/task-3001-dev2/scripts/youtube-pipeline/tests/ (4파일) | +673 | 변이 5종 전부 FAIL 후 복원 | verified |

---

## 검증 (명세 8항목, 전부 실측)

### 1. 변경 전 현재 동작 — 기록됨
`youtube_knowledge`(프로덕션 152건) · `insurance_chunks` · `youtube_channels` 만 기록.
`documents` 는 wiki 452 / daily 37 건이며 파이프라인 기여분 **0건**.
활성 채널 2개(`ch_ins_king`, `ch_보험명의정닥터`)의 `lastCrawledAt` 이 **2026-04-11 에 멈춰 있음**(4개월 이상 미갱신).

### 2. wiki 카드 실제 노출 — **실브라우저 확인 (픽스처 아님)**
실제 파이프라인을 videoId `Tvb1wqDwIB8` 로 **2회 실행**해 만든 카드가
**실제 Next.js 앱의 Wiki 탭에 렌더**되는 것을 스크린샷으로 확인.

- `/home/jay/workspace/memory/reports/task-3001-evidence/10-wiki-list-real.png`
  → Wiki 탭에 카드 "실손보험 꼭 필요한가요?(실비…", 작성자 "유튜브 지식 파이프라인", 실무 배지. 로그인 상태(우상단 아바타).
- `.../11-doc-detail-real.png` → 상세에 제목 + Gemini 생성 요약 본문 렌더
- `.../12-attachment-section.png` → "첨부 파일 (1)" 과 전사 파일명 노출
- `.../01-login-page.png` → 로그아웃 상태 대조군 (로그인월 오탐이 아님을 증명)

★ **렌더 환경 명시**: 프로덕션(`insuwiki.vercel.app`)은 Google OAuth + 화이트리스트라
자격증명이 없어 사용하지 않았다(회장 계정 사용 안 함). 대신 **실제 앱 소스를 Firebase 에뮬레이터
(firestore 8080 / auth 9099)에 붙여 로컬 dev 서버(:3100)로 구동**했다. 렌더링·라우팅·Firestore 조회는
전부 실제 앱 코드이며 목업이 아니다. 로그인만 `signInWithCustomToken` 으로 우회했다(커스텀 클레임 `role:'member'`).
**프로덕션 Firestore 에는 쓰지 않았다.**

### 3. 요약 분량·구조 실측 — 구조 PASS / **분량 상한 초과**
| 항목 | run1 | run2 |
|---|---|---|
| 전사 source | whisper_stt | whisper_stt |
| 전사 길이 | 2,087자 | 2,087자 |
| **요약 길이** | **2,480자** | **3,348자** |
| 하한(1,500) 재시도 | 0회 | 0회 |
| 상한(2,000) 초과 | **예** | **예** |

4단 구조는 전문 육안 확인 결과 **준수**(주제 한줄 / 핵심 주장 / 수치·조건·예외 / 실무 시사점 + 말미 고지문).
숫자·한도·기간·면책 1:1 대조 결과 **누락 0건** — 100만원, 70~80만원, 2018년 4월 단독가입 룰,
자동차·산재·건강보험 중복보장 배제, 고지의무 위반 시 거절/할증/부담보, 1년 갱신형, 5년 후 갈아타기,
광고심의 유효기간 1년이 전부 요약에 보존됨.
원문: `.../live-transcript.md` · 요약: `.../live-summary.md`

### 4. 중복 게시 방지 — PASS
동일 영상 2회 처리 후 에뮬레이터 `documents` 에서 `youtube-Tvb1wqDwIB8` **정확히 1건**.
`createdAt=2026-08-24T00:25:33.731Z`(1회차 값 보존) · `updatedAt=2026-08-24T00:28:09.248Z`(2회차로 갱신).

### 5. 첨부 권한 — **차단은 실증 / 허용은 미실증**
- **비로그인 차단 실증**: `curl` 무토큰 → **401** `{"error":"Unauthorized"}` · 위조 토큰 → **401** ·
  경로 조작(`..%2F..%2Fetc%2Fpasswd`) → **400**. 브라우저 컨텍스트에서도 401 재확인.
- **Drive 파일 실제 권한**: `permissions().list` 실측 결과 owner(개인 계정) + writer(서비스 계정) **2건뿐,
  `anyone` 권한 없음 = 비공개**. 명세가 우려한 "완전 공개" 상태는 **아니었다.**
- ★ **미실증**: "로그인하면 열린다"는 **로컬에서 증명하지 못했다.** 하네스의 `.env.local` 에
  Firebase Admin 자격증명(`FIREBASE_CLIENT_EMAIL`/`FIREBASE_PRIVATE_KEY`)이 없어
  `admin.initializeApp()` 이 호출되지 않고 `verifyIdToken()` 이 `app/no-app` 으로 실패 → **로그인 상태에서도 401**.
  즉 인증 통과 경로는 **vitest 목 기반 10건으로만** 검증됐다. 실환경 양성 경로 확인은 **배포 후 필요**.
  이를 "통과"로 포장하지 않는다.

### 6. 기존 경로 무손상 — PASS
- `firestore_writer.py` diff = 순수 추가(0 deletions). `youtube_knowledge`/`insurance_chunks` 쓰기 함수 무변경.
- 실행 2회 후 두 컬렉션 모두 정상 생성, **임베딩 차원 768** 실측, `chunkText` 2,000자 truncate 유지.
- **프로덕션 벡터 검색 실측**(읽기 전용, 쿼리 "실손보험 보장 한도"):
  `youtube_knowledge` **HITS=5** · `insurance_chunks` **HITS=5** → task-2989 결과물 무손상.

### 7. 봉인 증명 — 변이 5종 전부 FAIL 후 복원
| 변이 | 결과 |
|---|---|
| `visibility` public → private | **2 failed** / 102 passed |
| `docType` wiki → daily | **2 failed** / 102 passed |
| 결정적 문서 ID 변경(멱등 파괴) | **22 failed** / 82 passed |
| 분량 하한 게이트 무력화(`if False`) | **1 failed** / 103 passed |
| `SUMMARY_MIN_LENGTH` 1500 → 0 | **3 failed** / 101 passed |
| 복원 후 | **104 passed** (잔여 diff 0) |

★ 변이는 malformed 입력이 아니라 **"정상 포맷인데 값이 틀린"** 사고 유형으로 구성했다.

### 8. 회귀 — 기준선 대비 유지 (base 재측정, 명세 수치 아님)
| 스위트 | base(`origin/master` 실측) | after |
|---|---|---|
| pytest (youtube-pipeline) | 66 passed | **104 passed** (신규 38) |
| vitest (nextapp) | 1331 passed / 26 failed | **1341 passed / 26 failed** (신규 10, 실패 동일) |
| tsc `--noEmit` | **91 errors** | **91 errors** (신규 파일 오류 0건) |

vitest 실패 26건은 전부 `src/app/api/ai/feedback/__tests__/route.test.ts` 로 **선재 실패**이며 내용·건수 동일.

---

## L1 스모크테스트
- **서버 재시작**: 성공 — 로컬 Next.js dev 서버(`next dev --webpack -p 3100`) 기동, `✓ Ready`
- **API 응답 확인**: `curl http://localhost:3100/api/attachments/{fileId}` → **401** `{"error":"Unauthorized"}` ·
  위조 토큰 → **401** · 경로 조작 → **400**. 실파이프라인 2회 실행(각 123.6초 / 137.3초) 성공.
- **스크린샷**: `/home/jay/workspace/memory/reports/task-3001-evidence/10-wiki-list-real.png`,
  `11-doc-detail-real.png`, `12-attachment-section.png`, `13-attachment-click.png`, `01-login-page.png`

---

## ★ ANU 판단 필요 — 발견 사항 5건 (고치지 않고 보고)

**F1. 요약 상한 2,000자를 상시 초과 (2,480자 / 3,348자, n=2 모두)**
회장 확정 규격은 1,500~2,000자다. 현재 구현은 **하한만 fail-closed** 이고 상한은 경고 기록(`length_exceeded_max`)뿐이다.
상한을 하드 실패로 두면 파이프라인이 쉽게 멎고, 압축 재시도를 넣으면 **"숫자·한도·기간·면책 축약 금지"**
원칙과 충돌할 수 있어 임의 판단하지 않았다. **규격 준수 방식은 회장/ANU 결정 사항.**

**F2. `config.py` 의 `GEMINI_API_KEY` 기본값이 무효 키** — `400 API_KEY_INVALID`.
env var 없이 프로덕션 크론이 돌면 **요약 생성이 항상 실패**한다. 검증은 별도 유효 키를 주입해 진행했다.
(별건) 같은 파일에 `YOUTUBE_API_KEY`/`DRIVE_CLIENT_SECRET`/`DRIVE_REFRESH_TOKEN` 이 **평문 기본값으로 커밋**돼 있다.
값은 이 보고서에 옮기지 않는다. **키 로테이션 필요** — 이 태스크 범위 밖.

**F3. YouTube 자막 tier1 이 상시 실패** — 시험한 영상 5개 전부 InnerTube API 가
`400 FAILED_PRECONDITION`. 조용히 2순위 Whisper 로 폴백 중이라 **모든 영상이 고비용 경로**를 탄다. 원인 미확정.

**F4. Whisper health 엔드포인트 불일치** — `config.py` 는 `/health`(404), 실제는 `/v1/health`.
`whisper_ok` 가 아무것도 게이팅하지 않아(`main.py:161-166`) 기능 영향은 없으나 **"Whisper 미응답" 오경보**를 상시 출력한다.

**F5. InsuWiki `PR Check` 워크플로 선재 실패 — 우리 변경 때문이 아님**
2026-04-16 이래 **전건 실패**. 실패 지점은 `TypeScript type check`(`npx tsc --noEmit`).
원인은 `src/hooks/__tests__/useDocumentReviewListener.test.ts`, `useReviewQueueListener.test.ts`,
`src/shared/types/__tests__/roles.test.ts` **3개 파일이 vitest 전역(`describe`/`it`/`expect`)을 import 없이 사용**해
`error TS2304/TS2582` **91건**이 나는 것. 다른 35개 테스트 파일은 `from 'vitest'` 로 명시 import 하고 있어 정상이다.
우리 신규 테스트도 명시 import 를 써서 **91 → 91 로 동일**하다. 수정은 이 태스크 범위 밖(별건 위임 권고).

---

## 모델 사용 기록
| 팀원 | 모델 | 담당 |
|---|---|---|
| 토르(백엔드) | sonnet | 파이프라인 조사 · ①② 구현 · 실행 검증 |
| 프레이야(프론트) | sonnet | nextapp 조사 · ③ 인증 프록시 |
| 헤임달(QA) | sonnet | 에뮬레이터 E2E 하네스 · 렌더 증명 |
| 오딘(팀장) | opus | 설계 · 명세 오류 3건 규명 · 변이 봉인 실증 · 벡터검색/CI 기준선 실측 · 통합 |

haiku 미사용. 팀장 직접 코딩 없음(변이 테스트·기준선 실측은 검증 행위).

## trip-wire 5종 실측
- Critical7 = **0** · PII net-new = **0** · 회귀 실패 = **0**(선재 26건 동일) ·
  forbidden_paths 침범 = **0**(`services/**`·`functions/**`·`data/**`·`.github/**` 무변경, `git diff --stat` 확인) ·
  nonce = **task-3001** 일치

## 남은 일
- PR 생성 → **ANU 검증 후 머지** (직접 머지 금지)
- 배포 후 **첨부 양성 경로(로그인 시 열림) 실환경 재확인** 필요 (§검증5 미실증 항목)
- F1~F5 는 ANU/회장 판단

## 세션 통계
- 총 도구 호출: 0회


## 세션 통계
- 총 도구 호출: 0회


## 세션 통계
- 총 도구 호출: 0회

