# task-3073 — 생성물 계측 하네스 (T-C)

- **팀**: dev2 (오딘) · **레벨**: Lv.2 · **일자**: 2026-08-31 KST
- **저장소**: `Jeon-Jonghyuk/InsuRo` · base `5e7c084` · 브랜치 `task/task-3073-dev2`
- **PR**: #285 (open) — https://github.com/Jeon-Jonghyuk/InsuRo/pull/285 · HEAD `6683050`
- **결론**: **하네스 가동 + 첫 기준선 10회 실측 완료. 첫 실행에서 표적 결함 3종을 전부 재현했다.**
- **머지**: **하지 않았다.** ANU 판단 사항.

---

## 0. ★★★ 첫 기준선이 곧바로 잡아낸 것 — 이 태스크의 실질 산출물

프로덕션급 프롬프트(10,246자·Hidden 플랜 7종 스킬·금소법 전체)로 sonnet·haiku 각 5회, **총 10회 실호출**. 성공 10/10.

### 결과 1 — 자수비: 10/10 전건이 목표를 넘겼다

목표 **1,975자**인데 본문 중앙값이 sonnet **3,527자** · haiku **3,049자**.

| | sonnet 중앙값 | sonnet p90 | haiku 중앙값 | haiku p90 |
|---|---:|---:|---:|---:|
| **자수비(본문/목표)** | **1.786** | **1.956** | **1.544** | **2.108** |
| 본문 자수 | 3,527 | 3,864 | 3,049 | 4,163 |

**1.5배 미만이 단 한 건도 없다.** 회장님이 겪으신 "1,975 지정 → 5,093 실제"(2.58배)가 예외적 사고가 아니라 **상시 상태**임이 실측으로 확인됐다.

### 결과 2 — ★ haiku 는 소제목을 5/5 전건 0개로 만든다 (모델 분리 측정이 결정적이었다)

| | sonnet | haiku |
|---|---:|---:|
| 소제목 수(■/▶) 중앙값 | **5** | **0** |
| 소제목 0개인 실행 | 1/5 | **5/5** |
| `##` 헤딩 수 중앙값 | 1 | **8** |

haiku 는 `##` 헤딩을 8~11개 쓰면서 `■`/`▶` 는 **한 개도 쓰지 않는다.** 채널 프롬프트가 "소제목은 ■ 또는 ▶ 기호 사용, **`##` 마크다운 아님**"이라고 명시하는데도 그렇다.

★ **명세가 "`##` 이 아니라 ■/▶ 로 세라"고 못박은 것이 정확했다.** `##` 만 셌다면 haiku 가 "소제목 8~11개로 가장 잘하고 있다"고 나왔을 것이다 — 정반대 결론이다.

★ **모델을 합산했다면 소제목 중앙값이 0.5 로 뭉개져** haiku 의 구조적 미준수가 통째로 사라진다. 분리 측정 요구가 옳았다.

### 결과 3 — ★ 래퍼가 10/10 전건 발생했고, 합산하면 자수비가 20% 부풀려진다

| | sonnet 중앙값 | haiku 중앙값 |
|---|---:|---:|
| 래퍼 발생 | **5/5** | **5/5** |
| 래퍼 자수 | 615 | 711 |
| 래퍼 비율 | 14.8% | 18.9% |

sonnet 중앙값으로 계산하면:
- 본문 기준 자수비 = 3,527 / 1,975 = **1.786** ← 하네스가 기록하는 값
- 전체 기준 자수비 = 4,219 / 1,975 = **2.136** ← 래퍼를 합산했을 때

**같은 산출물이 1.786 이냐 2.136 이냐가 갈린다.** 명세의 "래퍼를 합산하면 자수비가 부풀려진다"는 경고가 실측으로 입증됐다. 래퍼 발생 여부를 6번째 지표로 남기라는 요구도 정당했다 — **전건 발생이므로 "가끔 있는 일"이 아니다.**

### 결과 4 — 나머지 지표

| 지표 | sonnet 중앙값 / p90 | haiku 중앙값 / p90 |
|---|---:|---:|
| 질문형 소제목 비율 | 0.650 / 1.000 | **n/a (분모 0)** |
| FAQ 블록 수 | 1 / 1 | 1 / 1 |
| 키워드 정확일치 | 9 / 12 | 4 / 9 |
| 키워드 공백제거 | 10 / 13 | 4 / 9 |
| 소요 시간(초) | 139.6 / 177.4 | 163.7 / 205.4 |

- **haiku 질문형 비율이 `n/a`** 인 것은 소제목이 0개라 분모가 0이기 때문이다. 0 으로 위조하지 않은 설계가 여기서 값을 했다 — 0 으로 적었으면 "질문형을 안 쓴다"로 오독됐겠지만, 실제로는 **소제목 자체가 없다**는 전혀 다른 사실이다.
- **키워드는 정확일치와 공백제거가 실제로 갈린다** (sonnet 9 vs 10). 한쪽만 셌으면 놓쳤을 차이다.
- **최대 205.4초 · 420초 상한 대비 2.04배 여유 · 420초 초과 0건.** t3070 이 정한 상한이 유효함을 재확인.

### 스탬프 (전 10행 동일)

```
commit_sha            ababd1b9   git_dirty=False
prompt_structure_hash d7813a6f6a106eb8
prompt_chars          10246      target_chars=1975
harness_version       task-3073-v1
```

★ 측정은 `ababd1b` 에서 돌았고 PR head 는 `6683050`(main 동기화 머지)이다. **PR head 에서 dry-run 을 다시 돌려 `prompt_structure_hash` 가 `d7813a6f6a106eb8` 로 동일함을 확인**했으므로 이 측정값은 PR head 에 그대로 유효하다. 머지가 가져온 것은 t3071 의 `claim_extract/` 뿐이고 프롬프트 구성요소가 아니다.

---

## 1. 만든 것 — 19파일 3,320줄 (커밋 `ababd1b`)

| 파일 | 역할 |
|---|---|
| `server/scripts/bench_harness/metrics.py` | 6지표 계산 + 래퍼/본문 분리 (표준 라이브러리만) |
| `server/scripts/bench_harness/prompt_build.py` | 프로덕션 동일 프롬프트 조립 + 구조 해시 |
| `server/scripts/bench_harness/run_bench.py` | 실호출 러너 + JSONL 적재 + 중앙값/p90 |
| `server/scripts/bench_harness/fixtures/` | 5종 (프로덕션급 픽스처 · 금소법 스냅샷 · 실패 산출물 재현) |
| `server/scripts/bench_harness/tests/` | 7파일 **105건** (전부 오프라인, 실호출 0) |
| `server/scripts/bench_harness/README.md` | 설계 근거·실행법·"이게 아닌 것" |

`server/main.py` **diff 0줄** — `git diff server/main.py` 빈 출력.

---

## 2. 프로덕션 동일성 — 명세 검증 항목에 대한 답

명세: *"ANU 초기 재현이 2,098자로 불완전했고 봇 실측은 7,980자였다. 후자가 맞다"*

### 조립 **로직**은 바이트 단위로 프로덕션과 동일하다 (실증)

`prompt_build.py` 는 조립을 복제하지 않고 `main.build_content_prompt` 를 **직접 호출**한다. 이를 검증하려고 현재 `main.py` 에 t3070 의 입력값을 그대로 넣어봤더니 **7,980자가 나왔고 t3070 의 `prompt_FIXPROD.txt` 와 완전히 일치**했다.

추가로, 라벨링용 재구성 결과가 실제 반환값과 어긋나면 `assemble_prompt()` 가 **예외를 던진다**. main.py 의 조립 로직이 바뀌면 즉시 드러난다 — 조용한 드리프트가 불가능하다.

### 초기 픽스처는 5,391자였다 — 로직이 아니라 입력값 부족이었다

구성요소별로 분해하니 격차 2,589자의 88%가 딱 둘이었다: 금소법 프롬프트 1,624자 + 스킬 935자. 이를 채워 **10,246자** 프로덕션급 픽스처를 만들었다.

### ★ t3070 의 프롬프트가 프로덕션과 달랐던 지점 2건 (정직 보고)

명세가 준 수치를 그대로 쓰지 않고 `main.py` 를 따랐다. 그 결과 어긋난 곳:

| 구성요소 | t3070 값 | 본 실행 값 | 이유 |
|---|---:|---:|---|
| compliance | 1,683 | **1,761** | t3070 은 자체 포맷(`- [cat] text`)을 썼다. 프로덕션 실제 포맷은 `★ 필수 준수 사항:` 번호매김 + `☆ 권장 사항:` + 말미 지시문 |
| profile | 143 | **562** | t3070 은 손으로 쓴 최소 프로필. 본 실행은 disclaimer·광고표기·등록번호가 모두 켜진 프로덕션급 |

즉 **t3070 의 실호출 46회는 이 두 지점에서 프로덕션과 다른 프롬프트로 측정된 것**이다. 결론(마커 출력률)에 영향을 줬는지는 이 태스크의 범위 밖이라 판단하지 않는다 — ANU 가 알고 있어야 할 사실로만 남긴다.

### 포맷을 손으로 베끼지 않았다

`main.py` 의 금소법 전개 코드는 엔드포인트 함수 본문 인라인이라 import 가 불가능하다. 그래서 `tests/test_compliance_full.py` 가 **main.py 의 해당 분기를 AST 로 떼어내 실제로 exec** 한 결과와 바이트 단위로 비교한다. 프로필 블록도 같은 방식이다. 눈으로 대조한 것이 아니다.

---

## 3. "하지 말 것" 준수 — 코드 수준 증거

| 금지 | 확인 방법 | 결과 |
|---|---|---|
| 자동 재생성 루프 | n=3 전건 실패 픽스처 → 정확히 3회 호출·3행 기록 (재시도 0) | **테스트로 봉인** |
| 게이트 사용 | pytest 대상 밖 별도 실행. CI 워크플로 미등록 | **준수** |
| 프롬프트 문구 수정 | `git diff` 에 `generateOptions.ts`·`skill_prompts/` | **0건** |
| `server/main.py` 수정 | `git diff --stat server/main.py` | **빈 출력** |
| 비용 무한 반복 | `--max-calls` 초과 시 **CLI 호출 전** SystemExit(1), 파일 생성도 0 | **테스트로 봉인** |
| 타임아웃 초과 | 상한의 100배를 요구해도 `CONTENT_CLI_TIMEOUT_SECONDS` 로 clamp (숫자 하드코딩 없이 main 상수 참조) | **테스트로 봉인** |
| t3071·t3072 파일 교집합 | t3071=`claim_extract/**` 등 · t3072=`main.py`·`Generate.tsx` | **교집합 0** |

---

## 4. 통합 중 발견해 고친 결함 3건

1. **`REQUIRED_ROW_FIELDS` 에 `target_chars` 중복** → `METRIC_ONLY_FIELDS` 루프가 error 행에서 이미 아는 목표 자수를 `None` 으로 덮어썼다. 측정값이 아니라 입력값이므로 분류를 바꿨다.
2. **`error` 필드가 필수 목록에 없었다** → 실패 사유가 조용히 누락돼도 봉인이 안 잡혔다. 추가 + 4건 봉인.
3. **★ 질문형 소제목 검출기가 체계적으로 과소집계** → 옛 정규식은 `무엇|왜|어떻게|얼마` 를 **문장 끝** 패턴으로 요구했는데 한국어에서 이 의문사는 앞·중간에 온다. `인가`·`한가`·`비싼가` 도 `는가` 에 안 걸려 전부 놓쳤다. 같은 입력에서 **loose 검출 2 → 6 (22.2% → 66.7%)**.
   - strict(`?`/`？`) 정의는 손대지 않았고 `strict ⊆ loose` 계약 유지.
   - 어미를 문자열로 나열하지 않고 **받침으로 판정**(ㄴ받침+`가`, ㄹ/ㄴ받침+`지`)했다 — 나열이 빠뜨린 것이 애초의 사고였기 때문이다. 덕분에 `유지`·`복지`·`세 가지` 는 자동 제외된다.
   - 과잉검출 대조군 6종도 봉인했다. 개선 전/후 검출 수 차이를 assert 하므로 차이가 0이면 테스트가 실패한다.

---

## 5. 검증

- **하네스 테스트 105 passed** (전부 오프라인, 실호출 0)
- **서버 전체 회귀 3,102 passed / 1 failed / 4 skipped**
- **실호출 10/10 성공** (rc≠0 0건, 타임아웃 0건)

### 유일한 실패는 선재 결함이다

`tests/test_consultation_history_get.py::test_cors_fail_closed_when_ext_origin_unset`

테스트가 `INSURO_EXTENSION_ORIGIN` 을 env 에서 제거하고 서브프로세스를 띄우지만, `main.py:43` 의 `load_dotenv` 가 워크트리 루트 `.env:44` 에서 그 값을 **다시 주입**한다. `main.py`·`.env` 모두 이번 작업에서 손대지 않았고 `git status` 에도 나타나지 않는다. 메모리에 기록된 기지의 함정("워크트리 `.env` 가 CORS 테스트를 깨뜨린다")과 동일 건이다.

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

| 파일 | 검증 방법 | 상태 |
|---|---|---|
| server/scripts/bench_harness/metrics.py | 지표 봉인 18건 + 래퍼 분리 5건 + 질문형 개선 전/후 대조 + 실호출 10회 산출값 | PASS |
| server/scripts/bench_harness/prompt_build.py | 스탬프 16건 + AST-exec 바이트 대조 + 재구성 assert + 프로덕션 7,980자 재현 | PASS |
| server/scripts/bench_harness/run_bench.py | 행 필드 15건 + 모델분리 집계 13건 + 안전장치 10건 + 실호출 10행 적재 | PASS |
| server/scripts/bench_harness/fixtures | 금소법 스냅샷 main.py 포맷 일치 · 프로덕션 픽스처 10,246자 dry-run | PASS |
| server/scripts/bench_harness/tests | 105 passed · 전부 오프라인 | PASS |
| server/main.py | git diff 빈 출력 | 무수정 |

### Why 분석

- **1st Why** — 왜 목표 1,975자에 5,093자가 나왔는데 미리 못 잡았나? 단위 테스트가 "프롬프트에 규칙이 들어갔는가"만 보고 "모델이 따랐는가"를 보지 않기 때문이다.
- **2nd Why** — 왜 모델의 준수 여부를 보지 않았나? 실제 산출물을 재는 계측이 아예 없었다. 라이브 사고가 나야만 알 수 있는 구조였다.
- **3rd Why** — 왜 계측이 없었나? 계측을 붙이면 곧바로 게이트나 자동 재생성 루프로 이어질 것 같아 부담이 컸다. 미팅이 그 둘을 명시적으로 분리해(루프 철회·기록 전용) 계측만 먼저 붙일 수 있게 됐다. **되먹임이 없으므로 Goodhart 위험이 0이다.**

---

## S — 상황 (SCQA)

**S (Situation)** — 회장님이 목표 1,975자를 지정했는데 실제 5,093자가, 이미지 프롬프트 6개 지정에 0개가 나왔다. 저희가 미리 잡지 못했다.

**C (Complication)** — 단위 테스트는 프롬프트에 규칙이 들어갔는지만 본다. 게다가 프로덕션 실패 산출물의 `##` 헤딩 3개가 전부 모델이 만든 래퍼여서, 순진하게 `len(output)` 으로 재면 자수비가 부풀려지고 `##` 로 소제목을 세면 정반대 결론이 나온다.

**Q (Question)** — 프로덕션과 동일한 프롬프트로 실제 모델을 호출했을 때 6개 지표가 어떻게 나오는가? 래퍼를 분리하면 수치가 실제로 달라지는가? sonnet 과 haiku 가 다른가?

**A (Answer)** — 10,246자 프로덕션급 프롬프트로 10회 실호출한 결과, **자수비 10/10 전건 목표 초과**(중앙값 1.786/1.544), **래퍼 10/10 전건 발생**(합산 시 자수비 20% 부풀림), **haiku 소제목 5/5 전건 0개**(sonnet 중앙값 5). 명세가 요구한 세 가지 설계(래퍼 분리·■▶ 기준·모델 분리)가 전부 실측으로 정당화됐다.

---

## 6. ★★★ ANU 판단 요청 사항

### 6-1. 이 데이터로 무엇을 할 것인가 — 루프 재논의 시점

미팅 합의는 "위치 수정 → 측정 기록 → 데이터 → 그 다음 루프 재논의"였다. **데이터가 나왔다.** 다만 본 실행의 N=5/모델은 **첫 기준선**이지 결론용 표본이 아니다. 자수비처럼 10/10 전건인 항목은 이 표본으로도 단정할 수 있지만, 질문형 비율처럼 sonnet 만 값이 있는 항목은 표본이 부족하다.

### 6-2. haiku 소제목 0개 — 범위 밖이지만 중대하다

haiku 는 5/5 전건 `■`/`▶` 를 쓰지 않고 `##` 로 대체한다. 이 태스크는 **재는 것만** 하므로 프롬프트를 고치지 않았다. 조치 여부는 ANU 판단이다.

### 6-3. t3070 프롬프트와 프로덕션의 차이 2건 (§2)

compliance 포맷·profile 길이가 달랐다. t3070 결론의 재검토 필요 여부는 ANU 판단이다.

### 6-4. 금소법 스냅샷의 노화는 자동 감지되지 않는다

`fixtures/fcpa_checklist.json` 은 t3070 산출물에서 옮긴 **스냅샷**이다(DB 접속 0건). 프로덕션이 체크리스트를 고치면 낡아지고 **아무도 알아채지 못한다.** 픽스처 파일과 README 에 명시했으나 해소되지 않은 리스크다.

### 6-5. dispatch 상태 부재 — 자가해소하지 않았음

`memory/capabilities/task-3073.json` 스냅샷과 taskctl state 파일이 **둘 다 없었다**(직전 시도가 서버 리붓으로 중단된 흔적). PR 생성을 위해 `taskctl init→dispatch→ack→run→commit` 을 직접 실행했다. scope-guard 가 FAIL 을 내면 **스냅샷 부재 오탐**일 수 있으니 ANU 가 판단해 주기 바란다. 자가 override 하지 않았다.

### 6-6. Gemini 리뷰 TIMEOUT

PR #285 생성 시 `gemini_verdict: TIMEOUT` 이 떴다. Gemini 는 sunset 상태이므로 G3 fallback 판정이 필요하다. 어차피 머지는 ANU 몫이다.

---

## 7. 종결 상태

- PR **#285 open** · HEAD `6683050` · **머지 안 함**
- 회귀: 서버 3,102 passed (선재 실패 1건 §5)
- CI_WATCH_HANDOFF: `memory/events/task-3073.ci-watch-handoff.json`
- 측정 원본: `/tmp/t3073_baseline.jsonl` (10행) + `.summary.json`. `data/` 는 gitignore 되어 커밋되지 않는다
- `gh` 호출은 전부 `GH_TOKEN="$BOT_GITHUB_TOKEN"` 사용

## dev2-team 추가 항목

- **팀 구성**: 팀장 직접 코딩 0줄. 팀원 3명에게 위임(충실도 분석·테스트 보강·프로덕션 픽스처). 워크트리 충돌 방지를 위해 코딩 작업은 **순차 실행**했다.
- **직전 시도 산출물 재사용**: 발사자는 "산출물 0건"으로 알고 있었으나 디스크에 미커밋 1,457줄이 남아 있었다. 재작성하지 않고 **품질을 검증한 뒤 이어서** 작업했다(dry-run 동작·테스트 5건 통과 확인 후 판단).
- **버그 발견/수정**: 3건 (§4). 전부 이번 작업 산출물 내부의 결함이다.
