# task-3014 — /generate 카페 채널 추가 + AI 모델 선택(플랜 상한)

- **팀**: dev7-team (이참나) · **레벨**: Lv.2 · **일자**: 2026-08-25
- **repo**: InsuRo · **base**: `997fa5d` (origin/main) · **branch**: `task-3014`
- **커밋**: `cf02030`(쿠쿨칸/서버) · `78e927c`(카마소츠/테스트)

---

## S (상황)

회장 요구는 세 가지였다. ① `/generate` 에 카페 채널 추가(무료 포함 전 플랜) ② 고급옵션에서
Haiku·Sonnet·Opus·GPT 4종 선택 + 플랜별 상한 ③ GPT 는 아누 시스템 codex 경로 사용.

명세는 "플랜이 모델을 자동 결정하는 구조를 상한 검증으로 바꾸라"고 지시했다.

## C (복잡성) — 명세 전제와 실측이 4곳에서 어긋났다

명세의 grep 근거를 **전수 재확인**한 결과, 전제가 실제와 달랐다.

1. **서버 모델 선택 로직은 이미 있었으나 완전한 죽은 코드였다.**
   `server/main.py:1405-1428` 이 `claude_model` 을 계산·검증하지만 **사용처 0건**(grep 실측).
   실제 생성은 `cli_model = PLAN_LEVEL_CLI_MODEL.get(sort_order)`(구 1469) →
   `_run_content_cli_async(full_prompt, cli_model)`(구 1595). **사용자 선택은 생성에 전혀 도달하지 않았다.**

2. **Opus 는 현재 아무도 못 쓴다.** `PLAN_LEVEL_CLI_MODEL` 이 맥스(4)·히든(5)도 `"sonnet"`.

3. **`claude-3-5-haiku-20241022` 은 은퇴(retired) 모델이다.** 실호출 RC=1
   (`Claude 3.5 Haiku was retired on February 19, 2026`). 이 ID 가 `TIER_CLAUDE_MODELS`·
   `TIER_DEFAULT_CLAUDE`·`FRONTEND_MODEL_MAP` 에 박혀 있었다.
   ★ 명세대로 `claude_model` 을 생성 경로에 순진하게 결선했다면 **무료·베이직 생성이 즉시 깨졌다.**

4. **모델 선택 UI 는 이미 존재한다.** 다만 `allAiModels` 가 **Gemini 로 표기**되고 서버에서 Claude 로
   매핑된다(표기와 실제 모델 불일치는 이번 PR 이전부터 존재).

### ★ 범위 차단 (ANU 판단 필요)

카페 채널과 모델 목록의 **단일 진실원이 `allowed_resources` 밖**이다.

| 필요한 것 | 실제 정의 위치 | allowed_resources 포함 |
|---|---|---|
| 채널 목록 `channels` | `src/data/generateOptions.ts:4` | ❌ 미포함 |
| 모델 목록 `allAiModels`·`modelsByTier`·`planToTier` | `src/data/generateOptions.ts:462-489` | ❌ 미포함 |
| 허용/잠금 모델 산출 | `src/hooks/usePlanFeatures.ts` | ❌ 미포함 |
| 명세가 지목한 `planSkillMap.ts` | 스킬·이미지 티어만 있음 | ✅ 포함(단 **모델/채널 로직 없음**) |

명세는 `src/config/planSkillMap.ts` 에 모델 매핑이 있다고 보았으나 실제로는 없다.
`channels` 는 `generateOptions.ts` **단일 정의**(grep 전수 확인)이므로, `Generate.tsx` 안에 카페를
따로 적는 우회는 **정의 2벌**을 만들어 알려진 dead-metadata 결함을 재생산한다 → 하지 않았다.
capability 스냅샷(`memory/capabilities/task-3014.json`)은 immutable 이며 **자가 확장 금지** 원칙에 따라
**프론트엔드는 착수하지 않고 ANU 에 범위 확장을 요청**한다.

## Q (질문)

프론트엔드가 막힌 상태에서 무엇을 완결할 수 있는가? → 명세가 "**서버 강제가 없으면 미완**"이라고
못박은 **서버 측 전량**이다. 서버는 프론트 없이도 독립적으로 실증 가능하다.

## A (답변) — 서버 측 전량 구현 + 실증

### ① 카페 채널 — 전 플랜 개방
`PLAN_LEVEL_CHANNELS` 레벨 **0~5 전부**에 `naver-cafe` 추가. 무료(0,1)는 정확히
`["naver-blog","naver-cafe"]` — 다른 채널은 열지 않았다(상한 이완 방지).

### ② 모델 상한 체계 (신설)
★ `PLAN_LEVEL_MODEL_TIER` 를 **재사용하지 않았다.** 그 값은 `feature_token_costs.model_tier`
**과금 조회**(line ~1543)에 쓰이므로, 재해석하면 과금 티어가 조용히 바뀐다.
→ 별도 `PLAN_LEVEL_MODEL_CAP` 신설, 과금 티어는 **원래 값 그대로 보존**(테스트로 봉인).

```
PLAN_LEVEL_MODEL_CAP: 0,1=haiku · 2,3=sonnet · 4,5=premium
GENERATION_MODELS: haiku/sonnet/opus(engine=claude) · gpt(engine=codex)
MODEL_CAP_DEFAULT_MODEL: haiku→haiku · sonnet→sonnet · premium→opus
```
CLI 에는 **짧은 별칭(haiku/sonnet/opus)만** 전달 — 은퇴 ID 는 생성 경로에서 완전 배제.

### ③ 죽은 검증 → 살아있는 결선
`resolved_model` 이 정규화·상한검증을 거쳐 **실제 CLI 호출과 로그(`model_used`)까지 도달**한다.
상한 초과 요청은 **HTTPException 403**. 미지정/미인식은 **cap 기본 모델로 폴백**(cap 초과 불가).

### ④ codex(GPT) 경로 신설
`_run_codex_cli_async()` — 검증된 argv(`-s read-only --ephemeral -c approval_policy=never -C /tmp -o`),
프롬프트는 **stdin**, 결과는 `-o` 임시파일(finally 삭제).
★ RC≠0/타임아웃/**빈 출력**이면 **예외** — 조용한 실패 신설 없음.

---

## 검증 결과

### 1. 플랜 × 모델 매트릭스 (실측, 20조합 전수)

| 플랜 (sort_order) | Haiku | Sonnet | Opus | GPT |
|---|---|---|---|---|
| 무료 (1) | 허용 | 거부(베이직↑) | 거부(맥스↑) | 거부(맥스↑) |
| 베이직 (2) | 허용 | 허용 | 거부(맥스↑) | 거부(맥스↑) |
| 프로 (3) | 허용 | 허용 | 거부(맥스↑) | 거부(맥스↑) |
| 맥스 (4) | 허용 | 허용 | 허용 | 허용 |
| 히든 (5) | 허용 | 허용 | 허용 | 허용 |

회장 요구(Free→Haiku, Basic·Pro→Haiku·Sonnet, Max·Hidden→4종)와 **일치**.

### 2. 모델별 실호출 — ★ 4종 전부 성공

**`server/main.py` 의 실제 생성 함수를 경유**해 호출한 결과(모의·추정 아님):

| 모델 | engine | CLI 인자 | 결과 | 소요 | 생성 첫 문장(발췌) |
|---|---|---|---|---|---|
| Haiku | claude | `haiku` | ✅ SUCCESS | 8.4s | 실손보험 가입할 때 자주 놓치는 "특약" 하나가… |
| Sonnet | claude | `sonnet` | ✅ SUCCESS | 7.8s | 실손보험, 매년 보험료만 오르고 정작 청구할 땐… |
| Opus | claude | `opus` | ✅ SUCCESS | 7.7s | 실손보험료가 또 올랐다는 소식에 통장을… |
| GPT | codex | (stdin) | ✅ SUCCESS | 4.9s | 실손보험, 제대로 알고 챙기면 병원비 부담을… |

**실패한 것**: `claude-3-5-haiku-20241022`(전체 ID) → **RC=1, 2026-02-19 은퇴**. 생성 경로에서 배제 완료.

### 3. 서버 상한 강제 실증
프론트를 우회해 상한 밖 모델을 직접 요청하는 시나리오를 재현 → **전부 거부**(위 매트릭스).
판정은 프론트 상태와 무관하게 `server/main.py` 상수만으로 이뤄진다.

### 4. 하위호환 (프론트 미변경 상태에서 회귀 없음)

| 현 프론트 전송값 | → canonical | 무료 | 프로 | 히든 |
|---|---|---|---|---|
| `google/gemini-2.5-flash` | haiku | 허용 | 허용 | 허용 |
| `google/gemini-2.5-pro` | sonnet | 403 | 허용 | 허용 |
| `anthropic/claude-sonnet` | opus | 403 | 403 | 허용 |
| (미지정) | cap 기본 | haiku | sonnet | opus |
| 미인식 문자열 | cap 기본 | haiku | sonnet | opus |

### 5. 봉인 — 변이(mutation)로 실증
`PLAN_LEVEL_MODEL_CAP[1]` 을 `haiku`→`premium` 으로 이완하자 **정확히 무료 관련 5건만 FAIL**:
```
FAILED …[무료(so=1)-sonnet-거부] / [무료(so=1)-opus-거부] / [무료(so=1)-gpt-거부]
FAILED test_seal_free_plan_cap_is_haiku
FAILED test_seal_non_premium_plans_cannot_reach_premium_models[1]
5 failed, 47 passed
```
원복 후 `git diff server/main.py` 비어 있음 확인 · 52 passed 복귀 확인.

### 6. 회귀 (base 재측정 기준선)

| 항목 | base (`997fa5d`) | head | 판정 |
|---|---|---|---|
| `pytest server/tests` | 2694 passed, 4 skipped | **2746 passed, 4 skipped** | ✅ +52(신규 테스트분), 실패 0 |
| `npx vitest run` | 94 files / 1374 tests | **94 files / 1374 tests** | ✅ 동일 |
| `npm run build` | EXIT=0 | **EXIT=0** | ✅ |

---

## L1 스모크테스트

- **서버 재시작**: 해당없음 — 본 PR 은 **미머지·미배포** 상태이며, 명세상 `server/main.py` 변경은
  **머지 후 배포해야 반영**된다. 대신 아래 실서버 모듈 로딩 + 실제 생성 함수 호출로 대체 검증했다.
- **API 응답 확인**: `server/main.py` 를 **실제 import** 하여 상수 로딩 확인 후,
  **생성 함수(`_run_content_cli_async` / `_run_codex_cli_async`)를 직접 호출**해 4종 모델이
  모두 실제 한국어 콘텐츠를 반환함을 확인(위 §2 표, RC=0 · 4.9~8.4초).
  HTTP 엔드포인트 전체 호출은 Supabase 인증·과금 테이블 의존으로 **미수행**(추정으로 통과 처리하지 않음).
- **스크린샷**: 해당없음 — 프론트엔드 변경이 **범위 차단**으로 미착수(§C 참조).

---

## trip-wire 5종 (실측)

| 항목 | 실측 | 근거 |
|---|---|---|
| Critical7 | **0** | red-team base 5건 → head 6건, 델타 1건은 `asyncio.create_subprocess_exec` **오탐**(고정 argv 리스트·shell 미사용·프롬프트는 stdin → 주입면 없음). 기존 `_run_content_cli_async` 도 동일 패턴으로 base 에서 이미 검출됨 |
| PII net-new | **0** | diff 추가분 정규식 스캔 0건 |
| 회귀 실패 | **0** | 2694 → 2746, 실패 0 |
| forbidden_paths 침범 | **0** | 변경 파일 2개 모두 allowed 내 |
| nonce | **task-3014** | 일치 |

## 수정 파일별 검증 상태

| 파일(절대경로) | 변경 | 검증 | status |
|---|---|---|---|
| /tmp/t3014-wt/server/main.py | +155/-47 | AST OK · import OK · 4종 실호출 성공 · 회귀 0 | `verified` |
| /tmp/t3014-wt/server/tests/test_model_cap_task3014.py | +223 (신규) | 52 passed · 변이로 봉인 실증 | `verified` |

---

## ★ ANU 판단 필요 (임의 판단하지 않음)

1. **프론트엔드 범위 확장 요청** — `src/data/generateOptions.ts`, `src/hooks/usePlanFeatures.ts` 를
   `allowed_resources` 에 추가해 재위임 필요. 이것 없이는 카페 채널 노출·모델 4종 UI·잠금 표시가
   **사용자에게 보이지 않는다**(서버는 이미 준비 완료).
2. **표기 불일치(기존 결함, 이번 PR 이 일부 확대)** — 현 UI 는 `🎵 Claude Sonnet(최고 품질)` 로
   표기되나 이번 변경으로 맥스·히든에서 **Opus 가 실행**된다. 설명("최고 품질")과는 부합하고
   사용자에게 불리하지 않으나, **라벨과 실제 모델이 다르다**. ①의 프론트 PR 에서 라벨을
   Haiku/Sonnet/Opus/GPT 로 정정하면 해소된다. 즉시 정정이 필요하면 지시 바란다.
3. **`plan_ai_models` DB 오버라이드의 의미 축소** — 종전에는 DB `model_tier` 가 허용 모델 범위까지
   올릴 수 있었으나, 이제 **과금 티어에만** 영향하고 상한은 플랜으로 고정된다.
   명세("플랜이 상한을 정한다")에 부합하나 **관리자 예외 경로가 사라진다**.
4. **`token_usage_log.model_tier`/`model_used` 에 `opus`·`gpt` 값이 최초 유입** — 해당 컬럼이
   enum 제약인지 **미확인**(DB 스키마는 범위 밖).
5. **`PLAN_LEVEL_CLI_MODEL` 은 이제 미참조** — 지시에 따라 삭제하지 않고 주석과 함께 보존.

## 완료 범위 / 미착수 범위

- ✅ 완료: 카페 채널 전 플랜 개방(서버) · 모델 상한 서버 강제 · codex(GPT) 경로 · 4종 실호출 실증 ·
  매트릭스 20조합 · 변이 봉인 · 회귀 유지 · 죽은 검증 결선 · 은퇴 모델 배제
- ⛔ 미착수(범위 차단): 카페 채널 **UI 노출** · 모델 4종 **선택 UI 및 잠금 표시**
  → 사유는 §C, 해소 조건은 ANU 판단 1번

**PR 생성까지가 범위 — 직접 머지하지 않았다.**

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

