# task-3064 — 벤치마킹 글작성 Phase 3: 환각 차단 + 제목 규칙 + 톤 우선순위 + 이미지 프롬프트

- 팀: dev3-team (다그다)
- 저장소: Jeon-Jonghyuk/InsuRo · 브랜치 `task/task-3064-dev3` · PR #277 (draft)
- base: `9b4c4f5` · head: `402771d` · 13파일 +1656/-7
- 상태: **PR 까지 완료. 머지·배포는 ANU.**

---

## S — 상황
Phase 1(스킬 노출)·Phase 2(수집·저장·자동삭제)가 머지·배포된 뒤, 회장이 벤치마킹 스킬로
생성해 보니 **일반 생성과 다를 바 없는 글**이 나왔다. 제목은 벤치마킹 대상 제목을 추종했고,
1위 글 본문은 근거로 쓰이지 않았다.

## C — 문제
회장의 최상위 제약은 **환각 금지**다("거짓정보를 활용하면 안 된다!! 큰일나!!!!").
그런데 근거가 모델에 전달조차 되지 않으면, 모델은 자기 기억으로 수치를 지어낸다.
이 조직 선례로 t2986 보험금 4.67배 과대추정, t2969 전 행 NEW_ONLY 오표기가 있다.

## Q — 질문
1위 글 본문을 근거로 실어 원문에 없는 수치가 들어가지 않게 하면서, 제목은 대상을 따르지 않고,
구조는 대상을 따르되 톤·타겟은 사용자 설정을 따르게 할 수 있는가.

## A — 답변
할 수 있다. **실제 1위 글로 3케이스를 생성해 원문외 숫자 0건**을 실측했다.
다만 이번 태스크의 실질은 프롬프트 설계가 아니라 **서버 배선 결함 1건을 찾아 고친 것**이다
(반증② 참조). 그것을 고치지 않았으면 프롬프트를 아무리 잘 써도 효과는 0이었다.

---

## 1. ★★ 명세 전제 반증 2건 (둘 다 ANU 가 사실로 확인)

### 반증 ① — 서버가 benchmark 스킬을 조용히 드롭하고 있었다 (회장이 본 증상의 진짜 원인)
`server/main.py:505` `PLAN_SKILL_ACCESS` 에 `benchmark` 가 **어느 플랜에도 없었다.**
`:1591` 의 `filtered_skills = [s for s in requested_skills if s in allowed_skills]` 에서
소리 없이 걸러졌다. Phase 1 이 프론트 `planSkillMap.ts` 에만 스킬을 추가했고,
**서버에 같은 목록이 따로 있다는 걸 명세가 놓쳤다.**

- 즉 Phase 1 이 붙인 스킬의 **서버 측 효과는 0** 이었다.
- 조치: `PLAN_SKILL_ACCESS[5]` 에만 `benchmark` 추가(Hidden 전용). 0~4 에는 넣지 않았다.
- 프론트 `planSkillMap.ts` 도 `Hidden` 배열에만 있어 **양쪽 목록이 이제 일치**한다.
- `skill_prompts/benchmark.txt` 는 여전히 부재하나 **무해**하다 — 벤치마킹 프롬프트는
  `load_skill_prompt()` 가 아니라 `build_benchmark_prompt()` 가 별도로 조립한다.
- 같은 누락이 `infokeyword_analyze` 에도 있으나 **별건이라 건드리지 않았다**(지시대로).

### 반증 ② — `top1_body_truncated` 는 DB 에서 읽을 수 없다. 파생 판정해야 한다
명세는 "`top1_body_truncated` 를 반드시 읽어라"고 했으나, **Phase 2 스키마에 그 컬럼이 없다.**
`char_count` 로 대신할 수도 없다 — `analyzer._char_count` 는 **공백 정규화 후 길이**라
raw `body_text` 길이와 단위가 다르다(실측 대조: `char_count`=1,975 vs `len(body_text)`=1,982).

- 처방: `len(body_text) >= TOP1_BODY_MAX_CHARS(20,000)` 로 **서버가 직접 파생 판정**한다.
  근거는 analyzer 가 `len(text) > 상한` 일 때만 `text[:상한]` 으로 자르므로 **절단분 길이는 정확히 상한**이다.
- `>=` 로 보수적 판정한다. 잘렸는데 모르는 쪽이 위험하기 때문이다.
- 클라이언트가 보낸 플래그는 신뢰하지 않는다(서버가 재계산).

---

## 2. 변경 내용

| 계층 | 무엇 |
|---|---|
| 서버 | `BenchmarkGrounding` 요청 모델 + `build_benchmark_prompt()` — 환각금지·원문상태·제목규칙·우선순위·복제금지·이미지규칙·출력범위 7블록 조립 |
| 서버 | `build_content_prompt(benchmark_prompt=...)` — 스킬 다음·채널 프롬프트 **앞**에 삽입(가장 강한 제약을 앞에) |
| 서버 | `PLAN_SKILL_ACCESS[5]` 에 `benchmark` 결선 (반증①) |
| 프론트 | `benchmarkRequest.ts` — 서버 계약대로 payload 조립. 근거가 `ok` 아니면 **null** 반환 |
| 프론트 | `Generate.tsx` 가드 — 벤치마킹 선택 + 근거 비`ok` 면 **fetch 자체를 안 한다** (조용한 일반생성 폴백 차단) |
| 프론트 | `BenchmarkTruncationNotice.tsx` — 절단 사실을 화면에 노출 |
| 프론트 | `imagePromptSection.ts` + `ImagePromptSection.tsx` — 마커 기준 본문/프롬프트 분리, 개수 불일치 경고, 복사 버튼 |

---

## 3. ★★ 검증① 환각 대조표 — **원문외 숫자 0건**

실제 저장 행으로 생성했다. 키워드 `대장암 자가진단` · 1위 글 1,982자 · 이미지 6장 ·
대상 제목 `대장암초기증상자가진단체크리스트 — 나는 해당될까?`
생성은 프로덕션과 동일 경로(`claude -p --model sonnet --output-format text`, stdin 프롬프트).

허용 근거 = ① 1위 글 원문 ② 사용자 타겟팅 설정값. 숫자에 **단위를 붙여** 대조했다
(자릿수만 보면 "90"이 원문 딴 데 있어도 통과하므로 약하다).

| 케이스 | 숫자 토큰 | 원문 근거 | 사용자 설정 | **원문외** |
|---|---|---|---|---|
| 블로그 / 친근한 | 32 | 23 (+ 단위상이 1) | 8 (30대) | **0** |
| 블로그 / 전문적인 | 29 | 26 | 3 (30대) | **0** |
| 카페 / 친근한 | 34 | 30 | 4 (30대) | **0** |

핵심 수치는 원문과 **문맥까지 일치**한다:

| 생성물 | 원문 |
|---|---|
| 1기에 발견하면 5년 생존율이 90% 이상 | 1기에 발견하면 5년 생존율이 90% 이상이에요. |
| 만 50세 이상은 2년마다 국가 무료 검진 | 만 50세 이상이라면 2년에 한 번 국가 무료 대장암 검진을 |
| 3개월 이내 체중이 5% 이상 감소 | 3개월 이내에 체중이 5% 이상 줄었다면 |
| 가족 중 대장암 환자 (위험 2배 이상) | 직계 가족 중 대장암 환자가 있는 경우 (발병 위험 2배 이상) |

★ 정직 고지 — 경계 1건: 블로그/친근한에 `50대 이후에도` 가 있다. 원문에는 `50세` 는 있으나
`50대` 는 없다. **사실 주장이 아니라 시점 표현**이고 수치를 새로 만든 것이 아니므로
환각 0건 판정에 포함하지 않았다. 판단 근거를 남기니 ANU 가 재판정할 수 있다.

## 4. 검증② 절단 플래그 — 프롬프트·화면 양쪽

| 측 | 근거 |
|---|---|
| 프롬프트 | 비절단 → `원문은 전문(全文)이다.` / 절단 → `원문은 수집 상한(20,000자)에서 잘렸다. …원문에 없다는 이유만으로 어떤 사실을 '거짓'이라고 단정하지 마라.` 두 문구가 **다름**을 실측 |
| 화면 | `BenchmarkTruncationNotice` 렌더. **이번에 테스트를 새로 만들었다** — 컴포넌트와 배선은 있었으나 이를 단언하는 테스트가 **하나도 없어 봉인 구멍**이었다(아래 F1·F2 변이가 무사통과했을 것) |

경계값 20,000자(절단) / 19,999자(비절단) 대조군을 양쪽에 두었다.

## 5. 검증③ 표절 아님

| 케이스 | 문장수 | 원문 완전일치 | 유사도>=0.85 | 8-gram 중복률 |
|---|---|---|---|---|
| 블로그 / 친근한 | 63 | **0** | 5 | **9.0%** |
| 블로그 / 전문적인 | 58 | **0** | 1 | **8.0%** |

★ 정직 고지 — 유사도 0.85 이상 항목은 전부 **20~40자짜리 짧은 사실 나열**이다
(`비만, 흡연, 음주가 해당되는 경우`, `국민건강보험공단 홈페이지…`). 고유명사와 위험인자
목록은 바꿔 쓰면 사실이 왜곡되므로 근접이 불가피하다. 문서 전체 중복률 8~9% 는 낮다.

## 6. 검증④ 제목 규칙 — 3케이스 전부 PASS (블로그·카페 양쪽)

| 케이스 | 생성 제목 | 키워드 선두 | 대상과 다름 | 대상 유사도 |
|---|---|---|---|---|
| 블로그/친근한 | 대장암 자가진단, 30대 신혼이라면 지금 확인하세요 | O | O | 0.364 |
| 블로그/전문적인 | 대장암 자가진단, 신혼부부가 지금 챙겨야 하는 이유 | O | O | 0.400 |
| 카페/친근한 | 대장암 자가진단 정리했습니다 | O | O | 0.417 |

★ 카페 케이스는 저장된 카페 행이 **DB 에 없어**(전체 1행, blog) 채널·제목·이미지수를
카페 형태로 바꾼 **합성 케이스**다. 원문은 동일하다. 합성임을 명시한다.

## 7. 검증⑤ 톤 연동 — 문체는 바뀌고 구조·분량은 유지

| 지표 | 친근한 | 전문적인 | 판정 |
|---|---|---|---|
| 해요체 어미 | 29회 | 5회 | 문체 **뒤집힘** |
| 합쇼체 어미 | 1회 | 22회 | 문체 **뒤집힘** |
| 본문 분량 | 2,143자 | 2,159자 | 차이 **0.7%** (대상 1,975자 기준 +8.5%/+9.3%) |
| 섹션 헤딩 | 6개 | 6개 | **동일** |
| 두 본문 간 유사도 | 0.172 | | 문장은 완전히 다시 씀 |

= 회장 승인 규칙 (a) "1위 글의 뼈대에 내 목소리를 입힌다" 가 **둘 다** 성립.
사용자가 톤을 안 고르면(`자동`) 대상 논조를 따르도록 프롬프트에 기본 동작을 명시했다.

## 8. 검증⑥ 이미지 프롬프트 — 개수 일치·한글·구분 출력

**프로덕션 파서(`splitImagePrompts`)로 실제 생성물을 파싱**했다(눈으로 센 것이 아니다).

| 케이스 | 대상 이미지 수 | 파싱된 프롬프트 | 본문 분리 | 한글 |
|---|---|---|---|---|
| 블로그/친근한 | 6 | **6** | O | O |
| 블로그/전문적인 | 6 | **6** | O | O |
| 카페/친근한 | 3 | **3** | O | O |

본문에서 마커 이후가 제거됨(`body` 에 `## 이미지 프롬프트` 없음)을 단언했다.

**★ 스크린샷 실촬영** — `/tmp/t3064/screenshot_image_prompts.png` (1166×2046).
Playwright + vite dev(8080) 로 실제 `/generate` 화면을 띄웠다. 화면에 실제로 보인 것:
본문이 마크다운으로 렌더되고, 그 아래 **테두리·배경색이 다른 별도 카드**에
`이미지 프롬프트` 제목 + `전체 복사` 버튼 + **번호 매긴 한글 프롬프트 6개**(각 항목에 복사 버튼).
"구획을 찾지 못했습니다" 경고도, 개수 불일치 경고도 없다.
★ 로그인월/빈 화면이 아님을 **이미지를 눈으로 확인**했다(픽스처 아님, 실제 생성 텍스트).
★ 가로챈 요청 body 로 배선도 확인: `skills=["base","benchmark"]` ·
`benchmark.url=https://blog.naver.com/allforlin_s2/224277275760` · `benchmark.imageCount=6`
→ **조용한 일반생성 폴백이 아님이 요청 수준에서 실증**됐다.
★ spec·config 는 전부 `/tmp/t3064/e2e/` 에만 만들었다(저장소 `git status` 청결, 커밋 0).

## 9. 검증⑦ 근거 없음 / 만료 — 조용한 폴백 없음

`missing` · `expired` · `error` 세 상태 모두 **`fetch` 가 호출되지 않고** destructive toast 가
뜬다. 대조군 2종이 핵심이다: 벤치마킹 미선택 시 일반 생성은 **정상 진행**되고,
근거 `ok` 면 요청 body 에 `benchmark` 키가 **실제로 들어간다**. 대조군이 없으면
"무조건 차단" 변이로도 테스트가 통과한다.

## 10. 검증⑧ 회귀 — base/head 같은 환경 재측정

| 스위트 | base `9b4c4f5` | head `402771d` | 델타 |
|---|---|---|---|
| pytest (server/ 에서) | 5 failed · **2987** passed · 4 skipped | 5 failed · **3004** passed · 4 skipped | **+17** |
| vitest | 1 failed · **1760** passed (121파일) | 1 failed · **1802** passed (126파일) | **+42** |

실패 집합이 base 와 head 에서 **완전히 동일**하다(`diff` 결과 차이 0) → **선재 확정**.

- pytest 선재 5건: `test_standby_fallback.py` 4건 + `test_consultation_history_get.py::test_cors_fail_closed_when_ext_origin_unset` 1건
- vitest 선재 1건: `push-utils.test.ts > VAPID 키를 얻지 못하면 구독하지 않고 false`
- 전부 우리 변경과 **교집합 없음**(푸시·네이버키 standby·CORS — 벤치마킹 경로 아님)

## 11. 검증⑨ 봉인 변이 7종 — 전부 no-op 아님 선증명 후 FAIL

변이가 no-op 이면 "테스트가 안 깨진 것"이 봉인 부재인지 변이 무효인지 구분할 수 없다.
그래서 **변이 전후 산출물(조립된 프롬프트 전문)을 실제로 비교해 달라짐을 먼저 증명**했다.

| # | 변이 | no-op 아님 증명 | 봉인 | FAIL 테스트 |
|---|---|---|---|---|
| M1 | 근거(`body_text`) 전달 제거 | O — 원문 58줄이 프롬프트에서 사라짐 | O | 1건 |
| M2 | `benchmark_body_truncated` 를 항상 False | O — 절단 문구가 전문 문구로 바뀜 | O | 2건 |
| M3 | 제목 규칙 블록 제거 | O — 제목 규칙 3줄 사라짐 | O | 1건 |
| M4 | 이미지 개수를 대상값+1 로 어긋냄 | O — `정확히 6개` → `7개` | O | 2건 |
| F1 | `isBenchmarkBodyTruncated` 항상 false | O | O | 2건 |
| F2 | 화면 절단 경고 렌더 블록 제거 | O | O | 2건 |
| F3 | (역변이) 항상 true — 대조군 | O | O | 3건 |

F3 역변이까지 FAIL 한다 = "항상 경고" 변이로도 통과하지 않는다(대조군 유효).
전 변이 원복 후 GREEN 재확인, `git status` 청결.

## L1 스모크테스트

- **서버 재시작**: 해당없음 — 이 PR 은 머지 전이고 배포는 ANU 소관이다. 프로덕션 서버 미접촉.
- **API 응답 확인**: 해당없음(엔드포인트 신설 없음 — 기존 `/api/insuro/generate-content` 확장).
- **스크린샷**: **있음** — `/tmp/t3064/screenshot_image_prompts.png`.
  Playwright + vite dev(8080) 로 실제 `/generate` 화면 렌더. 이미지 프롬프트 카드에
  한글 프롬프트 **6개**가 본문과 구분되어 보이는 것을 **눈으로 확인**(픽스처 아님).
- **★ 이 태스크의 L1 등가물 — 실제 AI 생성 왕복**:
  pytest/vitest PASS 는 프롬프트 문자열만 검사한다. 그래서 **프로덕션과 동일한 CLI 경로**
  (`claude -p --model sonnet --output-format text`, stdin 프롬프트)로 **실제 3회 생성**해
  산출물을 검사했다. 결과: 원문외 숫자 **0건** · 제목 3/3 규칙 준수 · 이미지 프롬프트
  **6/6/3 = 대상값 일치**. 이것이 "테스트 GREEN ≠ 실동작"을 메우는 근거다.
- **★ 요청 수준 실증**: E2E 에서 가로챈 실제 요청 body 에
  `skills=["base","benchmark"]` · `benchmark.imageCount=6` ·
  `benchmark.url=https://blog.naver.com/allforlin_s2/224277275760` 가 실려 있었다
  → 근거가 서버로 **실제 전달**되며 조용한 일반생성 폴백이 아님이 확인됐다.
- **미확인으로 남기는 것**: 프로덕션 실서버에서의 왕복은 **머지·배포 후에만** 가능하다.

## 12. 수정 파일별 검증 상태

| 파일 | 변경 | 검증 상태 |
|---|---|---|
| server/main.py | 근거 프롬프트 조립 + PLAN_SKILL_ACCESS 결선 | verified — pytest 17건, 변이 M1~M4 |
| server/tests/test_task3064_benchmark_grounding.py | 서버 봉인 15건 | verified — 전건 GREEN |
| src/pages/Generate.tsx | 근거 배선 + 폴백 차단 + 절단 노출 + 프롬프트 분리 | verified — 가드 5건, 절단 6건, 변이 F2 |
| src/pages/__tests__/Generate.benchmarkGuard.test.tsx | 폴백 차단 봉인 | verified — 5건 GREEN |
| src/pages/__tests__/Generate.benchmarkTruncation.test.tsx | 화면 절단 봉인 (신규) | verified — 6건 GREEN, 변이 F1·F2·F3 |
| src/components/generate/benchmarkRequest.ts | payload 조립 + 절단 판정 | verified — 12건 |
| src/components/generate/imagePromptSection.ts | 마커 분리 파서 | verified — 10건 + 실생성물 3건 |
| src/components/generate/ImagePromptSection.tsx | 구분 렌더 + 개수 경고 | verified — 9건 |
| src/components/generate/BenchmarkTruncationNotice.tsx | 절단 경고 | verified — 신규 테스트 6건 |
| src/data/generateOptions.ts | 상한 미러 상수 | verified — 서버값 드리프트 감지 테스트 |

## 13. 3 Step Why — 왜 회장 눈에 "일반 생성"으로 보였는가

- **1st Why**: 벤치마킹 스킬을 골랐는데 일반 글이 나왔다.
  → 1위 글 본문이 생성 요청에 실리지 않았다(`Generate.tsx` 미배선).
- **2nd Why**: 왜 Phase 1 에서 스킬을 붙였는데 효과가 없었나?
  → 서버 `PLAN_SKILL_ACCESS` 에 `benchmark` 가 없어 **조용히 드롭**됐다.
- **3rd Why**: 왜 아무도 몰랐나?
  → 스킬 허용 목록이 **프론트·서버 두 벌**로 존재하는데 명세가 프론트만 봤고,
    필터가 **에러 없이 조용히 제거**한다. 실패가 보이지 않는 구조였다.

## 14. ★ ANU 판단 요청 (범위 밖 / 결정 필요)

1. **`infokeyword_analyze` 도 같은 누락** — `planSkillMap.ts` Hidden 에는 있으나
   서버 `PLAN_SKILL_ACCESS` 에 없다. **별건 지시라 건드리지 않았다.** 처리 여부 판단 필요.
2. **`skill_prompts/benchmark.txt` 부재** — 현재 무해하나(별도 조립), 향후 누가
   `load_skill_prompt("benchmark")` 를 믿고 쓰면 빈 문자열을 받는다.
3. **카페 실데이터 부재** — `benchmark_sources` 전체 **1행**(blog)뿐이라 카페는 합성 검증.
   실카페 행이 생기면 재확인 권장.
4. **Supabase mock 함정(E2E 자산 개선 제안)** — 기존 `tests/e2e` 참조 spec 은 REST mock 을
   전부 **배열**로 반환하는데, `use-user-plan.ts`·`use-is-admin.ts` 는 `.maybeSingle()` 이라
   `Accept: application/vnd.pgrst.object+json` 로 **단일 객체**를 기대한다. 배열을 주면
   `organization_id` 가 undefined 가 되어 **플랜이 조용히 Free 로 폴백**한다(에러 없음).
   참조 spec 을 그대로 베끼면 시나리오가 무성 실패한다. `tests/e2e/**` 가 범위 밖이라
   고치지 않았다.
5. **스킬 목록 이중 소스 자체** — 프론트/서버 두 벌이 드리프트하면 또 조용히 드롭된다.
   단일 소스화 또는 대조 테스트가 필요하나 `planSkillMap.ts` 가 forbidden 이라 손대지 않았다.

## ★ scope-guard FAIL = 스냅샷 부재 오탐 (자가해소 금지 — ANU 판단 필요)

```
[scope-guard] ERROR: snapshot 없음 — dispatch 미수행?
              (/home/jay/workspace/memory/capabilities/task-3064.json)
[SCOPE-GUARD] FAIL — 머지 차단 + .escalate 생성
```

**범위 위반이 아니다.** 판별 근거:

| 판별 항목 | 실측 |
|---|---|
| task 파일에 allowed_resources | **있음** (2곳) |
| capabilities 스냅샷 파일 | **없음** |
| 같은 시기 다른 태스크 스냅샷 | 3052·3048·3046·3044·3042 **전부 있음** |

원인: 이 태스크는 **좀비 강제종료 후 재개**라 `dispatch.py` 를 경유하지 않고 직접
투입됐다. 스냅샷은 dispatch 시점에만 기록되므로 부재한 것이다.

**독립 검증 — 실제 diff 는 범위 안이다** (13파일 전수, 직접 glob 대조):

| 판정 | 건수 |
|---|---|
| allowed_resources 매칭 | **13 / 13** |
| forbidden_paths 침범 | **0** |
| 범위 밖 | **0** |

`planSkillMap.ts` 는 **읽기만** 했고 diff 에 없다. `naver_benchmark/**`·`supabase/migrations/**`·
`extension/**`·`.github/workflows/**` 전부 미접촉.

★ 규율에 따라 **스냅샷을 스스로 만들지 않았다**(self-bypass 금지). ANU override 요청.

### 시크릿 스캔 (변경분)

| 패턴 | 건수 |
|---|---|
| ghs_ / ghp_ / github_pat_ | 0 |
| eyJhbGciOi (JWT) | 0 |
| SERVICE_ROLE_KEY / BEGIN PRIVATE KEY / supabase.co | 0 |
| 추적된 .pyc · dist/ · .env 변경 | 0 |

## 15. 실행 계약 확인

- worktree 사용(`/home/jay/projects/InsuRo/.worktrees/task-3064-dev3`), 메인 저장소 미접촉
- 기존 브랜치에 이어서 커밋, **force push 없음**
- 임시 미리보기 파일(`__t3064_*`) **재생성하지 않음** — 검증 산출물은 전부 `/tmp/t3064/`
- 금지 경로 미접촉: `planSkillMap.ts`(읽기만) · `naver_benchmark/**` · `supabase/migrations/**` · `.github/workflows/**` · `extension/**`
- 이미지 **실제 생성 안 함** — 프롬프트 텍스트만
