# task-3023 — /generate 네이버 카페 채널 + AI 모델 4종 결선 (프론트)

- **팀**: dev5-team (마르둑) · **레벨**: Lv.2 · **날짜**: 2026-08-26
- **저장소**: `Jeon-Jonghyuk/InsuRo` · **브랜치**: `task-3023-generate-cafe-models` · **PR**: #258 (draft)
- **base**: `origin/main = 14e9596`
- **머지**: ANU 담당. 자동 머지 금지 (FINALIZE_ONLY)

## S — 상황
서버(t3014, `14e9596`)는 이미 `naver-cafe` 를 전 플랜에 개방하고 모델 4종
(`haiku/sonnet/opus/gpt`)을 canonical key 로 확정해 **프로덕션 배포까지 끝난 상태**였다.
그런데 프론트가 그 계약을 쓰지 않아 회장 지시 기능이 **실효 0** 이었다.

## C — 복잡성
이 태스크는 **재개**다. 이전 세션은 서버 global OOM(10:34)으로 사망했고,
브랜치에 검증 전 작업분(`e7c2dc6`)만 남아 있었다. 그 작업물의 **검증·완성**이 범위였다.

## Q — 질문
화면이 실제로 서버 계약대로 동작하는가? (빌드 통과가 아니라 **사용자가 고를 수 있는가**)

## A — 답변 / 수행 결과

### ① 네이버 카페 채널 — 완료
- `channels` 에 `naver-cafe` 추가. 서버 문자열과 **바이트 단위 완전 일치**
- 카페 프롬프트는 블로그 복사가 아님. "카페 vs 블로그 절대 혼동 금지" 규칙을 명시한
  커뮤니티/질문형/경험담 톤 전용 프롬프트

### ② 모델 4종 교체 — 완료
- 서버 `GENERATION_MODELS` 4개 canonical key 와 1:1 (접두어 없음)
- Gemini 3종 제거. 빌드 산출물의 Generate 청크에 `gemini` **0건** 실측
- **★ ModelTier 명명 결정**: 프론트 `opus` → **`premium`** 으로 변경.
  근거 = **서버가 정본**이며 서버 cap 이름이 `haiku|sonnet|premium` 이다.
  또한 `premium` cap 에는 `opus` 와 `gpt` **두 모델**이 속하므로, cap 이름을 `opus` 로 두면
  "cap 이름 == 모델명" 충돌이 생겨 `gpt` 를 설명하지 못한다.

### ③ 플랜 상한 화면 반영 — 완료
- 상한 초과 모델은 `disabled` + `🔒 업그레이드 필요` + 클릭 시 플랜 업그레이드 다이얼로그

### ★ 추가로 발견·수정한 실차단 결함 (이번 작업의 핵심)
데이터 계층만 고쳤을 때 **테스트 98건이 전부 GREEN 인데도 실 브라우저에서는 카페가 막혔다.**
`src/pages/Generate.tsx:398` 의 하드코딩이 서버 개방을 무력화하고 있었다:
```
- const isLocked = isFree && ch.value !== "naver-blog";   // 무료는 naver-blog만
+ const isLocked = isFree && !freePlanChannels.includes(ch.value);
```
이전 세션이 `freePlanChannels`(서버 미러)를 만들어 두고 **배선 전에 죽어서** 죽은 코드로 남아 있었다.
이 한 줄이 DoD("화면에서 카페를 고를 수 있다")의 실제 차단 지점이었다.

## 수정 파일별 검증 상태

| 파일 | 변경 | 검증 |
|---|---|---|
| src/data/generateOptions.ts | 카페 채널+프롬프트, 모델 4종, ModelTier premium, 기본값 haiku | 빌드 산출물 실측 · 테스트 |
| src/hooks/usePlanFeatures.ts | 초기 defaultModel gemini→haiku, 주석 정정 | tsc 0건 · 테스트 |
| src/pages/Generate.tsx | 카페 잠금 해제(freePlanChannels 단일 소스) | 실 브라우저 재현/해소 |
| src/data/__tests__/generateOptions.test.ts | 신규 계약으로 재작성 | vitest GREEN |
| src/hooks/__tests__/usePlanFeatures.test.ts | 신규 계약으로 재작성 | vitest GREEN |
| src/data/__tests__/channelLock.test.ts | 채널 잠금 봉인(신규) | 변이 FAIL 실증 |

## 검증 결과 (실측)

### 1. 서버 계약 일치 — PASS
서버 상수는 `ast.literal_eval` 로 `main.py` 에서 직접 추출(하드코딩 재입력 0), 프론트는 소스 파싱 후 대조.

| canonical | 서버 label | 프론트 label | 서버 min_cap | 프론트 tier | 판정 |
|---|---|---|---|---|---|
| haiku | Claude Haiku | Claude Haiku | haiku | haiku | O |
| sonnet | Claude Sonnet | Claude Sonnet | sonnet | sonnet | O |
| opus | Claude Opus | Claude Opus | premium | premium | O |
| gpt | GPT (codex) | GPT (codex) | premium | premium | O |

채널: 서버 6종 == 프론트 6종, `naver-cafe` 바이트 일치. 서버/프론트 전용 값 **없음**.

### 2. 플랜별 가시성 6종 — 상수 기준 PASS (단, §미해결 참조)

| sort_order | 서버 cap | 프론트 tier | 허용 모델 | 판정 |
|---|---|---|---|---|
| 0 Fallback | haiku | haiku | haiku | O |
| 1 무료 | haiku | haiku | haiku | O |
| 2 베이직 | sonnet | sonnet | haiku,sonnet | O |
| 3 프로 | sonnet | sonnet | haiku,sonnet | O |
| 4 맥스 | premium | premium | 4종 | O |
| 5 히든 | premium | premium | 4종 | O |

### 3. 카페 실호출 — PASS
HTTP 엔드포인트는 Supabase **ES256/JWKS** JWT 필수라 토큰 발급이 원천 불가(비밀키 Supabase 보유),
테스트 계정도 없다. 따라서 선례(t3014)와 동일하게 **in-process 실호출**로 수행:
채널 게이트 PASS → cap 게이트 PASS(폴백 아님, `resolved_model=haiku`) →
카페 프롬프트 조립(2,422자) → `claude -p --model haiku` **실제 생성 성공**.

- 소요 **21.78초** · 출력 747자 · 전문 `/tmp/t3023-verify/cafe-output.txt`
- **문체 판정 = 카페 톤 PASS**: 해시태그 0 · 블로그 소제목(■/▶) 0 · 이미지 위치 0 · 표 0 ·
  본문 536자(카페 대역 400~700, 블로그 1500~2000 아님) · 경험담 도입 1 · 구어체 어미 11 ·
  마지막 댓글 유도 질문 1
- HTTP 레이어 실측: 무인증 POST → **401**(fail-closed 정상). 라이브 서버는
  `/home/jay/insuro-releases/releases/14e9596.../server` 에서 구동 중이며 `naver-cafe` 포함 확인

### 4. 회귀 — base 직접 재측정
- **base(`14e9596`) 실측: 95 files / 1392 tests, 전량 PASS** (명세 수치 불신, 별도 worktree에서 직접 측정)
- 착수 시점 작업분: **31 failed** (구 Gemini 계약을 검증하던 테스트)
- **최종: 96 files / 1458 tests, 전량 PASS** (팀장이 직접 재실행해 확인)
- 델타 = +1 file / +66 tests (구 Gemini 테스트 49건 교체 + 신규 115건). **기존 테스트 회귀 0건**
- ★ 한계 명시: `vitest.config.ts` 는 `tests/e2e/**` 를 **제외**한다. 따라서 **vitest 전량 PASS ≠ 회귀 없음**.
  CI 의 `e2e-test` 잡은 본 보고 시점 **still in_progress** 이며, 최종 판정은 CI 결과에 따른다(ANU 확인 필요).

### 5. 봉인(변이) — PASS (no-op 아님 실증)
`freePlanChannels` 에서 `"naver-cafe"` 를 **임시 제거**하자 봉인 테스트가 **3건 FAIL**:
```
★ naver-cafe 는 잠금 해제여야 한다  → expected true to be false
★ ['naver-blog','naver-cafe'] 와 정확히 일치 → expected ['naver-blog'] to deeply equal [...]
naver-cafe 가 반드시 포함되어야 한다 → expected ['naver-blog'] to include 'naver-cafe'
```
원복 후 17/17 GREEN. 원복 검증 = `git diff --stat -- src/data/generateOptions.ts` **0줄**(팀장 직접 확인).

★ 정직한 한계: 표 구동 전수 블록은 양변이 모두 `freePlanChannels` 파생이라 이 변이에서 PASS 한다.
카페 누락을 실제로 잡는 축은 **독립 상수 대조 블록**이며, FAIL 한 것도 그 축이다.

### 6. 실 브라우저 — PASS
`npm run build` 성공 후 `vite preview` 실물 화면 확인.
`/generate` 는 로그인월 + 개인정보 처리위탁 동의 게이트가 있어, **Supabase REST 를 스텁**하고
클라이언트 세션을 주입해 실제 컴포넌트를 렌더했다(무료 플랜 상태 재현).

- `t3023-models-free-plan2.png` — **Free 플랜** 배지 · Claude Haiku 선택 가능 ·
  Sonnet/Opus/GPT **비활성 + 🔒 업그레이드 필요** · Gemini 0건
- `t3023-cafe-channel.png` — 수정 **전**: 무료 플랜에서 카페 클릭 시 "채널 제한" 토스트(결함 재현)
- `t3023-cafe-selected-FIXED.png` — 수정 **후**: 무료 플랜에서 **네이버 카페 선택됨**(파란 활성),
  우측 패널 "네이버 카페 / 커뮤니티 신뢰형 글쓰기" + 카페 전용 질문형 주제 6종 노출,
  티스토리·인스타그램·Threads·유튜브릴스는 **여전히 잠금**(다른 채널 정책 무변경 실증), 토스트 없음
  · URL `?tab=naver-cafe` 로 전이
- 빌드 산출물 실측: Generate 청크에 `naver-cafe` 존재, `gemini` 0건
  (잔여 gemini 3건은 전부 `AdminAIConfig` 청크 = 별개 관리자 기능, 범위 밖)

## ★ ANU 판단 필요 — 이번 범위 밖의 서버 결함 (유료 결제자 차단)
`server/**` 는 forbidden_paths 라 **손대지 않았다.** 그러나 실측 중 발견했다.

프로덕션 `subscription_plans` 실측(service role, 읽기 전용):
`Free:0 · Basic:1 · Pro:2 · Max:3 · Hidden:4` — **0-based, 영문 이름**
서버 `PLAN_ORDER`(`main.py:433`)는 `무료:1…히든:5` — **1-based, 한글 키** (영문 미매칭)
`main.py:710` `plan_row.get("sort_order") or PLAN_ORDER.get(name, 1)` — **`0` 이 falsy**

결과 실효 매핑:

| DB plan | DB sort | 서버 실효 sort | 서버 cap | 프론트 tier | 결과 |
|---|---|---|---|---|---|
| Free | 0 | 1 (falsy 승격) | haiku | haiku | 정상 |
| Basic | 1 | 1 | haiku | sonnet | **Sonnet 칩 열림 → 403** |
| Pro | 2 | 2 | sonnet | sonnet | 정상 |
| Max | 3 | 3 | **sonnet** | premium | **Opus/GPT 칩 열림 → 403** |
| Hidden | 4 | 4 | premium | premium | 정상 |

**베이직·맥스 결제자가 산 모델을 못 쓴다.** 자기 플랜 이름이 적힌 403이 뜬다.
- 이 결함은 **이번 변경이 만든 것이 아니다**(구 Gemini 매핑에서도 동일 계열로 존재). 다만 범위 밖이라 미수정.
- 근본 원인은 서버 3곳(`433` / `518` / `710`)이며 **DB↔서버 중 어느 쪽을 정본으로 삼을지**는
  회장/ANU 결정 사항이라 임의 수정하지 않았다.

## CI 상태 (보고 시점)
head `d1ac4b69` 기준 — 완료 8건 전부 success(`guard`·`hidden-path-audit`·`merge-safety-check`·
`gemini-review-gate`·`ci/guard`·`cancel-kill-switch`·`qc-check`·`lock-in-check`),
**진행 중 3건**(`ci`·`e2e-test`·`diagnostic`). `mergeable=true`, `mergeable_state=unstable`(체크 진행 중).
PR #258 **draft / open / merged=false** — REST 실측. 자동 머지 하지 않음.

## ★ scope-guard FAIL — 거짓 양성 (자가해소 금지, ANU 판단 필요)
`finish-task.sh` 가 1건 위반으로 머지를 차단했다:
`src/hooks/__tests__/usePlanFeatures.test.ts: paths 미포함`

**그러나 capabilities 스냅샷은 이 파일을 허용한다.** 스냅샷 실재하며 `src/**/__tests__/**` 포함.

근본 원인 = `scripts/task-scope-guard.sh::glob_match` 의 글롭 버그:
```python
if pattern.endswith("/**"):
    prefix = pattern[:-3]                 # 'src/**/__tests__'
    return path == prefix or path.startswith(prefix + "/")   # ← ** 를 literal 로 비교
```
패턴이 `/**` 로 끝나면 prefix 를 **문자열 그대로** `startswith` 비교한다. prefix 안의 `**` 가
전개되지 않으므로 `src/**/__tests__/` 로 시작하는 경로는 **존재할 수 없다**. 아래의 `**` 처리
분기(참을 반환)는 early return 때문에 **도달 불가**.

실측 재현:

| 패턴 | 경로 | prefix 분기 | ** 분기였다면 |
|---|---|---|---|
| src/**/__tests__/** | src/hooks/__tests__/usePlanFeatures.test.ts | **False** | True |

`src/data/__tests__/**` 는 prefix 에 `**` 가 없어 정상 통과했다 — 그래서 data 테스트만 통과하고
hooks 테스트만 걸린 것이다(이것이 결정적 단서).

**판정: 범위 위반 아님.** 변경 7파일 전부 allowed_resources 내부이고 forbidden 침범 0건이다.
규율대로 **자가해소하지 않았다**. 근거는 `memory/events/task-3023.scope-false-positive-evidence.json` 에 박제.
ANU 조치 요망: override 기록 또는 `glob_match` 수정 후 재실행.

## 트립와이어
- Critical7 = 0 · PII net-new = 0 · forbidden_paths 침범 = **0** · nonce = task-3023
- 변경 파일 6종 전부 `allowed_resources` 내부

## L1 스모크테스트
- **서버 재시작**: 해당없음 (프론트 전용 변경. API 서버는 기존 가동분 그대로 사용)
- **API 응답 확인**: `POST /api/insuro/generate-content` → **401**(무인증 fail-closed 정상 동작 실측).
  인증 경유 실생성은 in-process 로 **성공**(카페 글 747자, 21.78초)
- **스크린샷**: `t3023-models-free-plan2.png`(모델 잠금), `t3023-cafe-channel.png`(수정 전 결함), `t3023-cafe-selected-FIXED.png`(수정 후 카페 선택)
- **회귀**: base 95/1392 → 최종 **96 files / 1458 tests 전량 PASS**, 회귀 실패 **0**

## ★ ANU 참고 — `plan_ai_models.model_value` 유령 컬럼 (범위 밖, 미수정)
`usePlanFeatures.ts` 는 플랜별 허용 모델을 DB에서 덮어쓰는 분기를 갖고 있다:
`supabase.from("plan_ai_models").select("model_value")`

프로덕션 실측(REST, 읽기 전용):
```
select=model_value → HTTP 400 {"code":"42703","message":"column plan_ai_models.model_value does not exist"}
select=model_tier  → HTTP 200
```
실제 스키마는 `(plan_id, feature_key, model_tier, model_name)` 이고 `model_value` 를 추가하는
마이그레이션은 저장소에 **없다**. `types.ts` 에만 손으로 추가돼 있었다(`3101e12` "types.ts 누락 필드 추가").

**영향 판정**: 이 쿼리는 **항상 에러 → 로컬 매핑 폴백**이라 사용자 피해는 없다(안전한 죽은 분기).
결과적으로 프론트의 실효 정본은 `modelsByTier` 이며, 이는 이번 작업으로 서버와 정렬됐다.
다만 ① 죽은 분기가 남아 있고 ② `AdminSubscriptions` 는 `model_value` 로 **INSERT** 하므로
관리자 플랜 편집 경로는 런타임 실패 가능성이 있다. 별도 태스크 판단 요망.

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

