# task-3074 — 이미지 프롬프트 구획 근본 수정 (지시 충돌 해소)

- **팀**: dev1 · **레벨**: Lv.2 · **저장소**: `Jeon-Jonghyuk/InsuRo`
- **PR**: [#286](https://github.com/Jeon-Jonghyuk/InsuRo/pull/286) **open** · base `b4ec215` · head `b8a6328` · mergeable=true
- **브랜치**: `task/task-3074-dev1` · **범위**: PR 생성까지. 머지·배포는 ANU.
- **지시서 sha256(앞16)**: `6bbdc668904d60ec` (대조 일치)

---

## ★★★ 최상단 — 재현 결과 (지시서 D)

**증상은 재현했다. 다만 지시서가 지목한 메커니즘과는 다른 원인이었다.**

| 조건 | 마커 출력 | 본문 위치표시 | 판정 |
|---|---|---|---|
| BASE + 벤치마킹 블록 **있음** (opus 4 · sonnet 4) | **8/8 정상** | 4.0 / 3.5 | 결함 재현 **실패** |
| BASE + 벤치마킹 블록 **없음** (opus 3 · sonnet 3) | **0/6 전건 누락** | 4.7 / 5.0 | **재현 성공** |

재현 산출물은 프로덕션 실패물(`contents` `3dd87c52`, 2026-08-31 02:54:38 KST)과 정량 일치한다:
마커 0개 · 인라인 표시 **5곳** · 래퍼 구획 발생 · 4,494자.

### 지시서 가설과 다른 점 (정직 보고)

지시서는 "채널 프롬프트(①)와 벤치마킹 규칙(②)이 **공존**하며 싸운다"를 원인으로 지목했다.
그러나 **②가 들어 있는 조건에서는 BASE 도 8/8 정상**이었다. 즉 ①②의 공존만으로는 결함이 나지 않는다.
결함은 **②가 통째로 사라졌을 때** 100% 결정적으로 발생했다.

지시서의 "원문 20,000자 뒤에 채널 프롬프트가 와서 더 강하다"는 설명도 성립하지 않았다 —
`benchmark_sources` 전 3행의 본문은 1,982 / 3,067 / 1,982자로 **20,000자 근처가 아니다**.

---

## ★★★ 진짜 근본 원인 — 플랜 스케일 off-by-one (ANU 판단 필요)

```python
# server/main.py:1902
if BENCHMARK_SKILL_ID in filtered_skills and req.benchmark is None:
    raise HTTPException(400, ...)          # ← t3072 가 막은 쪽
benchmark_prompt = ""
if req.benchmark is not None and BENCHMARK_SKILL_ID in filtered_skills:
    benchmark_prompt = build_benchmark_prompt(req.benchmark)
```

`req.benchmark` 는 **있는데** `benchmark` 스킬이 `filtered_skills` 에서 빠지면
`benchmark_prompt` 가 **조용히 빈 문자열**이 되고 생성은 그대로 진행된다.
그 블록 안에 이미지 규칙 · 출력 범위(래퍼 금지) · 환각 금지 · 제목 규칙 · 분량이 **전부** 들어 있어 한꺼번에 사라진다.
프론트는 여전히 `expectedCount=6` 을 들고 있어 "구획을 찾지 못했습니다" 경고를 띄운다.

**왜 빠지는가 — DB 와 코드의 플랜 스케일이 1 씩 어긋나 있다.**

| | Free | Basic | Pro | Max | Hidden |
|---|---|---|---|---|---|
| DB `subscription_plans.sort_order` (실측) | 0 | 1 | 2 | **3** | 4 |
| 코드 `PLAN_ORDER` (무료…히든) | 1 | 2 | 3 | 4 | **5** |

- 실패 생성의 사용자 `5e8b5a07…` 는 **Max = sort_order 3** →
  `PLAN_SKILL_ACCESS[3] = ["base","naver_seo","geo"]` → `benchmark` 탈락.
- `benchmark` 를 담은 유일한 엔트리는 `PLAN_SKILL_ACCESS[5]` 인데
  **sort_order 5 인 플랜이 DB 에 없다** → 현재 **모든 사용자**에게 벤치마킹 스킬이 미적용.

**이것이 t3068·t3070 의 프롬프트 문구 수정이 세 번 다 효과가 없었던 이유다** — 두 태스크 모두 이 경로를 건드리지 않았다.

> ★ 이 PR 은 이 부분을 **고치지 않았다.** 누구에게 벤치마킹을 허용할지는 요금제 정책이고
> 지시서 범위(A/B/C/D) 밖이다. 선택지: (1) 스케일 정합(히든만) (2) Max 에도 허용
> (3) 조용한 드롭을 t3072 처럼 400 전환. **결정 없이는 증상이 그대로 재발한다.**
> (3)을 단독 적용하지 않은 이유: 회장님 워크플로를 즉시 하드 차단하게 된다.

### A/B 가 재현 조건에서 효과 0 임을 바이트로 입증

```
벤치마킹 블록 없음(재현 조건): BASE 3314자/86d9782356c5 · FIX 3314자/86d9782356c5 · 바이트 동일 → 효과 0
벤치마킹 블록 있음          : BASE 6888자/4130ddb07078 · FIX 7520자/7b5d6ba5f5b7 · 적용됨
```

재현된 조건에서 실제로 사용자를 구하는 것은 **C(프론트 폴백)** 뿐이다.

---

## ★ t3070 하네스 검증 (지시서 D-1 요구)

지시서 추정은 **절반만 맞다**.

| 하네스 | 채널 프롬프트 | 실제 조건 |
|---|---|---|
| `realcall_n.py` (BASE 10회) | `channel_prompt=""` | **미포함** — 지시서 추정 맞음 |
| `realcall_prod.py` (BASEPROD/BASEHARD 18회) | `channel_prompt.txt` 로드 | **포함돼 있었다** |

즉 t3070 은 채널 프롬프트를 포함한 조합을 18회 돌렸고 그래도 마커가 18/18 나왔다.
t3070 이 재현에 실패한 진짜 이유는 채널 프롬프트가 아니라 **벤치마킹 블록을 항상 넣었기 때문**이다
(프로덕션에서는 그 블록이 없었다).

t3070 하네스가 놓친 것 3가지도 이번에 채웠다: **cwd**(프로덕션은 읽기전용 release 디렉토리),
**실제 프로필**(t3070 은 "OO생명" 더미), **모델**(히든 기본은 `opus` 인데 t3070 은 sonnet/haiku 만).
셋을 다 채워도 블록이 있으면 8/8 정상이었다 — 변수는 블록 유무 하나였다.

---

## 작업 내용

### A — 이미지 지시 단일화 (지시서 A안 **(b)** 채택)

지시가 두 개이고 서로 반대였다. **개수까지 충돌**했다(채널 "3~5곳" vs `image_count`=6) —
실패 산출물의 본문 표시가 **정확히 5곳**인 것이 ①이 이겼다는 직접 증거다.

**(b) 선택 근거**: (a)처럼 채널의 이미지 항목을 없애면 네이버 SEO 실익(이미지 있는 글 우대,
본문 내 위치 표시)을 버린다. 회장님이 원하는 것은 **둘 다**다.
→ 본문 위치 마킹과 뒤쪽 프롬프트 구획을 **번호로 1:1 대응**시켜 한 절차로 통합.
전면 금지("본문 안에는 넣지 마라")를 "위치 표시는 넣되 **프롬프트 문장**은 구획에만"으로 정밀화 —
두 지시가 서로를 부정하지 않는다.

추가로 `neutralize_channel_image_guide()` 가 **벤치마킹 이미지 모드에서만** 채널 프롬프트의
경쟁 항목을 단일 소스 포인터로 교체한다. 문구 추가만으로는 경쟁 지시가 그대로 남아
t3070 과 같은 실패가 반복된다. 패턴 불일치 시 원문 보존(안전장치).

★ **비벤치마킹 일반 생성 영향 0** — 이 경로를 타지 않는다(테스트 3케이스로 봉인).

### B — 래퍼 금지: 열거형 → 구조 규칙

이름 열거는 계속 뚫렸다. 실측 우회 이름 3종:
`활용 가이드`(회장 스크린샷) · `발행 시 참고 사항`(이번 ANU 실호출) · `품질 체크리스트 결과`(t3070).
→ blacklist 를 **허용 목록(whitelist) + 종료 선언**으로 교체.
"허용된 구획은 이것뿐, 그 뒤에는 아무것도 없다" + "금지 여부는 **이름이 아니라 구조**로 판단한다".

### C — 프론트 폴백 (마지막 안전망)

- 엄격/느슨 마커를 모두 못 찾으면 본문의 `[이미지 삽입 위치 N — 설명]` 에서 복구(`source:"inline"`).
- ★★ **폴백은 본문을 자르지 않는다**(원문 전체 반환) — 오탐 유실을 구조적으로 차단.
- 폴백으로 건지면 **화면에 표시**한다(조용한 성공 금지).
- 설명이 비면 채택하지 않는다(오탐 방지).

**실제 프로덕션 실패 산출물 2건 적용 결과** — 둘 다 기존에는 0개였다.

| 산출물 | 복구 | 본문 보존 |
|---|---|---|
| `3dd87c52` (회장 3회차) | **5/5** | 4,496 / 4,496 바이트 동일 |
| `27ac814c` (t3070 실측) | **6/6** | 5,495 / 5,495 바이트 동일 |

---

## 검증

### 실호출 32회 (프로덕션 충실 재현)

| 조건 | 마커 | 개수정확 | 본문 위치표시 | 래퍼 |
|---|---|---|---|---|
| BASE (블록 있음) | 8/8 | 8/8 | 4.0 / 3.5 | 0/8 |
| **FIX (블록 있음)** | **8/8** | **8/8** | **6.0 / 6.0** | 0/8 |
| BASE (블록 없음 · 재현) | **0/6** | 0/6 | 4.7 / 5.0 | 발생 |

→ A 의 실측 효과: 본문 위치표시가 채널의 "3~5곳" 상한으로 흐르던 것이
**전 8회 모두 목표치 6 과 정확히 일치**하게 됐다.
→ 모델별 마커 출력률(지시서 요구): FIX opus 4/4 · sonnet 4/4. (t3070 은 sonnet 9/9 · haiku 9/9, opus 미측정)

### 회귀 0 — base `b4ec215` 재측정 대조

- pytest **3363 → 3375** (+12 = 신규 테스트 수와 정확히 일치), **실패집합 동일**
  (`test_rate_limit_still_enforced_after_reset_fixture` 1건은 base 에도 존재하는 선재 실패)
- vitest **1942 → 1954** (+12 = 신규), 실패 0 · `tsc --noEmit` 0

### 변이 테스트 9/9 사망

| 변이 | 결과 |
|---|---|
| A1 채널 이미지지시 중립화 호출 제거 | KILLED |
| A2 "다른 개수 지정은 무효" 선언 제거 | KILLED |
| A3 본문↔구획 1:1 대응 규칙 제거 | KILLED |
| A4 구 자기모순 문구 복원 | KILLED |
| B1 구조 규칙 → 구 열거형 원복 | KILLED |
| B2 "이름이 아니라 구조" 제거 | KILLED |
| C1 인라인 폴백 분기 제거 | KILLED |
| C2 폴백이 본문을 자르게 만듦 | KILLED |
| C3 화면 폴백 안내 제거 | KILLED |

- 주입 전 `assert 원문 in 소스` 로 **no-op 아님 선증명** · 종료코드는 파이프 없이 `returncode` 직접 판독
- ★ **최초 실행에서 A3 가 생존**했다 — 내 단언이 `"1:1" in rule or "차례로" in rule` 로 느슨해
  `or` 로 통과했다. 계약을 조여 사망시켰다. (A2 는 처음에 변이 주입 자체가 실패해 SKIP → 문자열 교정 후 KILLED)

### CI — 9/11, 신규 실패 0

`ci` 와 `e2e-test` 2건이 failure 다. **둘 다 만성 적색이며 이번 변경과 무관**하다.

| PR | e2e-test | ci | 결과 |
|---|---|---|---|
| #282 (t3070) | failure | failure | 9/11 로 머지됨 |
| #283 (t3071) | failure | failure | 9/11 로 머지됨 |
| #284 (t3072) | failure | failure | 9/11 로 머지됨 |
| #285 (t3073) | failure | failure | 9/11 로 머지됨 |
| **#286 (본 PR)** | failure | failure | **9/11 — 동일 시그니처** |

또한 `ci` 는 base 커밋 `b4ec215` 에서도 failure 다(선재 확인).
ruleset 필수 8종(cancel-kill-switch · qc-check · hidden-path-audit · lock-in-check ·
merge-safety-check · gemini-review-gate · ci/guard · guard)은 **8/8 success**.

---

## t3073 계측 하네스 동기화 (리베이스 중 발견)

작업 중 main 이 `20eef19` → `b4ec215`(t3073) 로 이동했다. t3073 의 `prompt_build.py` 는
main 의 조립을 거울처럼 재구성하고 불일치 시 예외를 던진다. A(2)의 채널 프롬프트 변환 때문에
그 가드가 **정확히 발화했다(18건)** — 설계대로 동작한 것이다.
거울에 같은 변환을 반영하고, 원문 그대로를 단언하던 t3073 테스트 2건을 새 계약으로 갱신했다
(legacy 분량 상한 6000→7000: 실측 6,008 vs prod 10,863 — "legacy 는 프로덕션급이 아니다"는 의도 보존).

★ 최초에 t3073 파일을 grep 했을 때 "무관"으로 판단했으나, 그 경로가 당시 워킹트리에 없어
**조용히 0건**이 나온 것이었다. 전체 회귀를 리베이스 후 다시 돌려서 잡았다.

## 계약 변경 (검토 요망)

`test_task3070_image_section_and_timeout.py::test_meta_wrapper_sections_are_forbidden` 이
`"래퍼 구획을 만들지 마라"` 라는 **열거형 문구**를 봉인하고 있었다. 그 열거가 곧 결함이므로
구조 규칙 봉인으로 갱신했다.

---

## 지시서 준수

- 분량/글자수 수정 **없음** · 모델 제한 추가 **없음** · SEO 노하우 on/off **미착수**(t3075)
- PR 까지만 — **머지·배포하지 않았다**
- `CI_WATCH_HANDOFF`: `memory/events/task-3074.ci-watch-handoff.json` (16필드, 필수 12 누락 0,
  `expected_files` 11건이 실제 diff 와 정확히 일치)
- GitHub 접근은 v3.6 하네스가 CLI 를 막아 **REST API** 로 수행(`BOT_GITHUB_TOKEN`).
  API 로 만든 커밋의 tree sha 를 로컬 리베이스 결과와 **대조 일치** 확인(`b46bbe71…`).

## 미해결 · ANU 판단 필요

1. **★★★ 플랜 스케일 off-by-one** — 정하지 않으면 증상 그대로 재발. **A/B 는 무력**하고 C 만 완충한다.
2. **조용한 드롭 자체** — `req.benchmark` 가 있는데 스킬이 필터링되면 경고 없이 전혀 다른 글이 나온다.
   t3072 가 반대 경우를 400 으로 막은 것과 **대칭인 구멍**. 400 전환은 회장님 워크플로를
   즉시 막게 되어 단독 결정하지 않았다.
3. **머지 후 `insuro-api` 재기동 필요** — 서버는 디스크 파일을 실행한다(현재 atomic release 구조,
   cwd=`/home/jay/insuro-releases/current/server`). 머지만으로는 반영되지 않는다.
4. **프론트(C)는 CF Pages 별도 배포 필요.**

---

## 산출물

- 재현 하네스: `teams/dev1/task-3074/realcall_3074.py` (cwd·프로필·모델까지 프로덕션 재현)
- 변이 하네스: `teams/dev1/task-3074/mutate.py`
- 프로덕션 실패 산출물: `teams/dev1/task-3074/prod_failing_output_3074.md` (`contents` `3dd87c52`)
- 실호출 결과: `res_BASE.json` · `res_FIX.json` · `res_NOBM.json` + 산출물 `o_*.md` 22건
- GitHub REST 유틸: `api_push.py` · `create_pr.py` · `ci_status.py` · `ci_compare.py` · `ci_history.py`
