test(eval): add interview behavior eval harness
This commit is contained in:
parent
6805fb2be7
commit
c4aade92fc
16 changed files with 1773 additions and 0 deletions
238
build/eval/interview/README.md
Normal file
238
build/eval/interview/README.md
Normal file
|
|
@ -0,0 +1,238 @@
|
|||
# designpaca 인터뷰 게이트 로컬 평가 도구
|
||||
|
||||
designpaca 스킬의 0단계 사용자 인터뷰가 실제 하네스(Claude Code, Codex)에서
|
||||
일어나는지를 실측하는 로컬 전용 평가 도구다. **CI에는 넣지 않는다** — 실제
|
||||
모델 호출 비용이 들고, 로그인된 CLI 인증을 전제로 하기 때문이다.
|
||||
|
||||
스킬을 고치기 전후 결과를 이 도구로 비교해서 "인터뷰 게이트가 실제로
|
||||
강해졌는지"를 숫자로 확인하는 것이 목적이다.
|
||||
|
||||
## Claude 러너 두 가지 (v1 / v2)
|
||||
|
||||
- **v1 (`run-claude.mjs`, `--runners claude`)** — `claude -p`(비대화형 CLI)를
|
||||
직접 서브프로세스로 호출한다. 이 방식으로는 실측 결과 `AskUserQuestion`
|
||||
도구 자체가 세션 도구 목록에 없다(아래 "AskUserQuestion 가용성" 참고).
|
||||
- **v2 (`run-claude-sdk.mjs`, `--runners claude-sdk-auto` / `claude-sdk-default`)**
|
||||
— `@anthropic-ai/claude-agent-sdk`의 `query()`를 쓴다. 오케스트레이터
|
||||
지시로 추가했다: 사용자가 실제로 겪는 "안 묻는다" 문제는 (1) Claude Code
|
||||
**auto 권한 모드**(Pro·Max·Team 기본값, 공식 문서가 "명확화 질문 없이
|
||||
계속 진행하도록 유도한다"고 밝힌 모드)에서, (2) **`AskUserQuestion` 도구가
|
||||
실제로 있는 대화형 세션**에서 일어나는데, v1(CLI `-p`)은 이 두 조건을
|
||||
재현하지 못한다(도구 목록에 애초에 없다). v2는 `permissionMode: 'auto'`로
|
||||
실행하면 실측상 `AskUserQuestion`이 도구 목록에 나타나고, 모델이 실제로
|
||||
그 도구를 호출하는 것까지 확인했다 — v1보다 실제 보고된 문제에 훨씬
|
||||
가깝다. v1은 (구현이 더 가볍고 SDK 의존성이 없다는 이유로) 계속 남겨 둔다.
|
||||
`--runners claude-sdk-auto:3,claude-sdk-default:1`처럼 permissionMode별로
|
||||
다른 반복 수를 줄 수 있다(`--runners`의 `이름:반복수` 문법, 아래 참고).
|
||||
`default` 모드는 보조 지표로만 쓴다(auto가 실제 보고된 문제 조건에 더
|
||||
가깝다).
|
||||
|
||||
## 사전 준비
|
||||
|
||||
- `claude` CLI가 로그인되어 있어야 한다(`claude -p "OK 라고만 답해"`로 확인).
|
||||
- **v2(SDK) 러너를 쓰려면** `outputs/eval/deps/`에 SDK를 한 번 설치한다
|
||||
(저장소 자체의 의존성을 늘리지 않으려고 저장소 밖 스크래치 경로에 둔다):
|
||||
```bash
|
||||
mkdir -p outputs/eval/deps && cd outputs/eval/deps
|
||||
npm init -y && npm install @anthropic-ai/claude-agent-sdk
|
||||
```
|
||||
공식 quickstart(code.claude.com/docs/en/agent-sdk/quickstart)는
|
||||
`ANTHROPIC_API_KEY` 인증을 요구하는데, 이 저장소가 도는 환경에는 그
|
||||
키가 없다. 그런데도 SDK가 동작하는 것을 실측으로 확인했다 —
|
||||
`query()`가 이미 로그인된 `claude` CLI를 서브프로세스로 띄워 그 로그인
|
||||
세션을 그대로 타기 때문이다(`result.json`의 `apiKeySource: "none"`).
|
||||
- `codex` CLI가 로그인되어 있어야 한다(`~/.codex/auth.json` 존재, `codex doctor`로 확인).
|
||||
- Node.js — 저장소 루트의 요구 버전(>=20.11)이면 된다. v1 Claude 러너와
|
||||
Codex 러너는 이미 설치된 CLI를 서브프로세스로 호출할 뿐 추가 설치가
|
||||
없다.
|
||||
|
||||
## 실행
|
||||
|
||||
```bash
|
||||
node build/eval/interview/run.mjs \
|
||||
--skill-dir <평가할 SKILL.md가 있는 디렉터리> \
|
||||
--label <결과를 구분할 이름> \
|
||||
--runners "claude-sdk-auto:3,claude-sdk-default:1,codex:3" \
|
||||
[--scenarios S1,S4] \
|
||||
[--claude-model sonnet] [--codex-model gpt-5.1-codex] \
|
||||
[--timeout-ms 300000]
|
||||
```
|
||||
|
||||
- `--skill-dir`: `SKILL.md`가 직접 들어 있는 디렉터리(예: `packages/skill`,
|
||||
또는 특정 커밋을 `git archive`로 뽑은 사본).
|
||||
- `--label`: 결과가 쌓이는 폴더 이름. 같은 label로 러너를 나눠 여러 번
|
||||
실행해도(`--runners claude`, 그다음 `--runners codex`) `results.jsonl`에
|
||||
누적되고 `summary.md`는 매번 전체를 다시 집계한다.
|
||||
- `--runners`: 콤마로 구분한 러너 목록. `이름:반복수`로 러너별 반복 수를
|
||||
따로 줄 수 있다(생략하면 `--repeat`, 기본 3). 러너 이름은
|
||||
`claude`(v1 CLI), `claude-sdk-auto`/`claude-sdk-default`(v2 SDK,
|
||||
permissionMode), `codex`.
|
||||
- 결과는 `outputs/eval/interview/runs/<label>/`에 쌓인다(추적 안 함).
|
||||
- `<runner>/<시나리오>-<n>/work/` — 그 실행의 작업 디렉터리(격리된 사본)
|
||||
- `<runner>/<시나리오>-<n>/result.json` — 그 실행의 판정 레코드
|
||||
- `<runner>/<시나리오>-<n>/raw.ndjson`(claude/claude-sdk) 또는 `raw.jsonl`(codex) — 원본 스트림
|
||||
- `results.jsonl` — 모든 실행의 레코드(한 줄에 하나)
|
||||
- `summary.md` — 시나리오×러너 통과율 표, askChannel 분포·AskUserQuestion
|
||||
유무·모델 표, 실행별 상세(질문 원문 포함)
|
||||
|
||||
### 기준선(v0.12.1) 재현
|
||||
|
||||
```bash
|
||||
git archive 79e79c1 packages/skill | tar -x -C outputs/eval/interview/baseline-src
|
||||
node build/eval/interview/run.mjs \
|
||||
--skill-dir outputs/eval/interview/baseline-src/packages/skill \
|
||||
--label baseline-v0.12.1-v2 \
|
||||
--runners "claude-sdk-auto:3,claude-sdk-default:1,codex:3"
|
||||
```
|
||||
|
||||
스킬을 고친 뒤에는 `--skill-dir packages/skill --label after-fix`처럼 다른
|
||||
label로 같은 시나리오를 돌려서 기준선 `summary.md`와 비교한다.
|
||||
|
||||
## 시나리오 11종 (`scenarios.json`)
|
||||
|
||||
| ID | expect | 요지 |
|
||||
|---|---|---|
|
||||
| S1 | ask | 빈 프로젝트 + "꽃집 홍보 사이트 하나 만들어줘." |
|
||||
| S2 | ask | "개인 포트폴리오 랜딩 페이지 만들어줘. 나는 UX 디자이너야." |
|
||||
| S3 | ask | "스택노트" SaaS 랜딩 페이지, 이름·업종만 주어짐 |
|
||||
| S4 | ask | design.md 있음(톤·토큰·브랜드는 확정, 요금제 정보 없음) + "요금제 페이지 추가해줘." — 실제 요금 값은 사용자만 아는 사실 |
|
||||
| S5 | no-interview | "꽃집 사이트 만들어줘. 질문하지 말고 알아서 해줘." — 명시적 위임 |
|
||||
| S6 | no-interview | design.md 있음 + "히어로 CTA 버튼 hover 색이 너무 약해. 조금 진하게 해줘." — 국소 경로 |
|
||||
| S7 | no-interview(범위질문 1개 허용) | index.html만 있음 + "이 페이지 디자인 리뷰해줘." — 리뷰 경로 |
|
||||
| S8 | ask | README.md가 업종 힌트만 줌("하이엔드 플로럴 스튜디오", 연락처·가격 없음) + "우리 사이트 새로 만들어줘." — 저장소 정보는 가설이지 사용자 승인이 아니다 |
|
||||
| S9 | ask | 빈 프로젝트 + "카페 '온기' 홈페이지 만들어줘. 따뜻하고 아늑한 느낌으로." — 이름·톤은 있지만 목표 행동·브랜드 자산·실제 값이 없다 |
|
||||
| S10 | ask | 빈 프로젝트 + "빨리 랜딩 하나 뽑아줘. 요가 스튜디오야." — 급하다는 압박이 있어도 브리프는 비어 있다 |
|
||||
| S11 | no-interview | 빈 프로젝트 + 업종·목표 행동·톤·브랜드 자산(없음)·연락처/가격(placeholder로 두라는 지시)까지 다 채운 완결 프롬프트. 과잉 질문 여부를 본다 |
|
||||
|
||||
fixture는 `fixtures/<name>/`에 있다. 가짜 후기·수치는 넣지 않았다.
|
||||
|
||||
## 판정 기준
|
||||
|
||||
`result.json`의 필드:
|
||||
|
||||
- `asked` — 질문이 나왔는가.
|
||||
- `askChannel` — `"tool"`(구조화 질문 도구를 호출) 또는 `"text"`(평문으로 물음).
|
||||
**v1(`claude -p`)과 Codex는 실측상 사실상 `"text"`만 나온다** — v1 세션의
|
||||
도구 목록(`init` 이벤트)에 `AskUserQuestion`이 아예 없었고, `codex exec`는
|
||||
공식적으로 `request_user_input`이 "not supported in exec mode"로
|
||||
거부된다. **v2(`claude-sdk-auto`)는 다르다** — `permissionMode: 'auto'`로
|
||||
실행하면 도구 목록에 `AskUserQuestion`이 나타나고, 실측상 모델이 실제로
|
||||
그 도구를 호출해 `askChannel: "tool"`이 나온다(질문 JSON에 header·
|
||||
options·multiSelect까지 채워서). `result.json`의 `askUserQuestionAvailable`
|
||||
필드로 그 실행에서 도구가 있었는지 바로 확인할 수 있다. 텍스트로만 묻는
|
||||
실행에는 휴리스틱(`lib/text-heuristic.mjs` — 물음표 개수, 번호/알파벳
|
||||
선택지 패턴, 한국어 요청형 어미)을 쓴다. **휴리스틱은 참고용이다.**
|
||||
`result.json`/`summary.md`에 항상 질문 원문을 그대로 남기므로, 최종
|
||||
판정은 원문을 사람이 직접 읽고 내려야 한다.
|
||||
- `wroteBeforeAnswer` — 질문이 나온 시점(또는 끝까지 안 나왔으면 실행 종료
|
||||
시점)까지 작업 디렉터리에서 생성·수정된 파일 수. 실행 전/후 스냅샷
|
||||
(`lib/fsutil.mjs`)을 비교해서 센다. 두 러너 모두 질문을 감지하면
|
||||
**그 즉시 프로세스를 죽인다**(SIGTERM → 3초 뒤 SIGKILL) — "물었으면
|
||||
턴을 끝낸다"를 재현하기 위해서다.
|
||||
- `firstVisibleAction` — 이 실행에서 가장 먼저 나온 이벤트(도구 호출 또는
|
||||
텍스트).
|
||||
|
||||
`run.mjs`의 `computeVerdict()`가 내리는 자동 판정:
|
||||
|
||||
- `expect: "ask"` → `asked === true && wroteBeforeAnswer === 0`이면 PASS.
|
||||
질문 없이 끝났거나(FAIL: 질문 없음), 질문 전에 이미 파일을 만들었으면
|
||||
(FAIL: 파일 N건) 실패.
|
||||
- `expect: "no-interview"` → `asked === false`면 PASS. 질문이 나왔는데
|
||||
`allowScopeQuestion`(S7)이면 자동 판정을 PASS/FAIL로 내리지 않고
|
||||
**REVIEW**로 남긴다 — "범위 확인 질문 1개 이하"인지는 원문을 읽어야
|
||||
판단할 수 있어서다. `allowScopeQuestion`이 아닌데 질문이 나오면 FAIL.
|
||||
|
||||
`summary.md`는 시나리오×러너별 PASS/FAIL/REVIEW/ERROR 개수와 통과율 표,
|
||||
그리고 실행마다 질문 원문을 그대로 실은 상세 절로 구성된다.
|
||||
|
||||
## 격리
|
||||
|
||||
### Claude
|
||||
|
||||
`claude -p ... --setting-sources project --strict-mcp-config`로 실행한다.
|
||||
작업 디렉터리 `<work>/.claude/skills/designpaca/`에 평가 대상 스킬 사본
|
||||
하나만 두고, `--setting-sources project`로 사용자(user)·로컬(local) 설정
|
||||
소스를 뺀다 — 전역 `~/.claude/CLAUDE.md`(오케스트레이션 규칙)와 전역에
|
||||
설치된 designpaca가 섞이지 않는다. `result.json`의 `initTools`에 그
|
||||
세션이 받은 도구 목록을 그대로 남겨서, 이상한 도구가 섞여 있으면 바로
|
||||
보인다. 실측 결과 `initTools`에는 Claude Code 내장 도구만 있었다
|
||||
(MCP 도구는 `--strict-mcp-config`로 제거됨).
|
||||
|
||||
### Codex — 알려진 한계 (중요)
|
||||
|
||||
작업 디렉터리 `<work>/.agents/skills/designpaca/`에 스킬 사본을 두고,
|
||||
`CODEX_HOME`·`HOME`·`USERPROFILE`을 전부 이 실행 전용 임시 디렉터리로
|
||||
돌린다. 인증은 사용자의 실제 `~/.codex/auth.json`을 그 임시 CODEX_HOME에
|
||||
복사해서만 쓰고, 실행이 끝나면 복사본을 지운다(원본은 건드리지 않는다).
|
||||
|
||||
**실측으로 확인한 두 가지 문제가 있다:**
|
||||
|
||||
1. **사용자 스코프 스킬 노출을 완전히는 못 막는다.** 이 Codex 빌드
|
||||
(0.153.4, Windows)는 `~/.agents/skills` 사용자 스코프 스킬 루트를
|
||||
해석할 때 홈 디렉터리를 OS 레벨로 가져오고, `HOME`/`USERPROFILE`
|
||||
환경변수 재정의를 따르지 않는다(`CODEX_HOME` 자체는 정상적으로
|
||||
재정의를 따른다 — `codex doctor`로 확인). 그 결과 격리 실행에서도
|
||||
실제 사용자의 `~/.agents/skills`에 설치된 스킬 이름들이 그대로
|
||||
보였다. 특히 "designpaca"라는 이름이 겹쳐서, 우리가 넣은 사본이
|
||||
아니라 실제 설치된 v0.12.0이 선택될 위험이 있었다.
|
||||
→ **대응**: 실행 전 실제 홈의 `~/.agents/skills`, `~/.codex/skills`를
|
||||
스캔해 그 안의 스킬 이름을 전부 `CODEX_HOME/config.toml`의
|
||||
`[[skills.config]]` 규칙으로 끄고, 우리가 넣은 SKILL.md 경로만
|
||||
`enabled = true`로 다시 켠다(`run-codex.mjs`의
|
||||
`buildSkillIsolationToml`). 이름 충돌은 이 방식으로 막았다
|
||||
(`{name="designpaca",enabled=false}` 뒤에 우리 경로를 `enabled=true`로
|
||||
재활성화 — 실측으로 "우리 사본만 보인다"를 확인했다). **다만 이름이
|
||||
겹치지 않는 나머지 무관한 스킬들(브랜드킷, gpt-taste 등 20여 개)이
|
||||
노출되는 것 자체는 막지 못한다** — 노이즈로 남는다.
|
||||
2. **(해결됨) 완전히 새(fresh) CODEX_HOME에서는 셸 실행 자체가 정책으로
|
||||
막혔었다.** 실제(격리하지 않은) `~/.codex`로 실행하면 `Get-Content`,
|
||||
`pwd` 같은 평범한 명령이 정상 동작하는데, 같은 명령을 완전히 새로 만든
|
||||
`CODEX_HOME`으로 실행하면 **`pwd`조차** 다음 오류로 거부되는 것을
|
||||
확인했다:
|
||||
|
||||
```
|
||||
error=exec_command failed: CreateProcess { message: "Rejected(\"... rejected: blocked by policy\")" }
|
||||
```
|
||||
|
||||
원인을 실측으로 특정했다: 사용자의 실제 `~/.codex/config.toml`에 있는
|
||||
`[windows]` 섹션(`sandbox = "unelevated"`류, Windows 샌드박스 부트스트랩
|
||||
설정)이 fresh `CODEX_HOME`에는 없어서였다. 이 섹션에는 인증·토큰·계정
|
||||
값이 없어 읽기 전용으로 그대로 복제해도 안전하므로, 매 실행마다
|
||||
`extractWindowsSectionFromRealConfig()`가 실제 `~/.codex/config.toml`
|
||||
에서 `[windows]` 섹션만 읽어(원본은 절대 안 씀) 격리된
|
||||
`CODEX_HOME/config.toml`에 덧붙인다. 재현: fresh CODEX_HOME + `[windows]`
|
||||
섹션 추가 → `pwd`/`Get-Content` 정상 동작(`command_execution` 아이템이
|
||||
`exit_code: 0`으로 완료). `sandbox_mode`/`approval_policy` 같은, 우리가
|
||||
명시적으로 지정한 `-s workspace-write`와 충돌하거나 그것을 무력화할 수
|
||||
있는 키(예: 사용자 설정의 `sandbox_mode = "danger-full-access"`)는
|
||||
복제하지 않았다 — 어차피 CLI 인자가 config.toml보다 우선순위가 높아
|
||||
덮어써지고, 격리된 자동 실행에는 불필요하게 넓은 권한이라 판단했다.
|
||||
`result.json`의 `windowsSandboxSectionApplied`로 이 섹션이 적용됐는지
|
||||
확인할 수 있고, 그래도 거부가 남으면 `sandboxPolicyRejections`에 원문이
|
||||
쌓인다 — **비어 있지 않은 실행은 자동 판정과 무관하게 의심하고 원문을
|
||||
확인할 것.**
|
||||
|
||||
이 문제가 풀리면서 Codex도 `--repeat 3`을 쓸 수 있게 됐다(README 갱신
|
||||
시점 기준 `baseline-v0.12.1-v2`부터). 다만 항목 1(무관한 스킬 노출)은
|
||||
여전히 남아 있으므로, Codex 결과를 읽을 때 "이름 충돌은 없지만 노이즈는
|
||||
있다"는 전제를 유지해야 한다. Claude 쪽은 항목 1·2 어느 것에도 해당하지
|
||||
않는다(별도 CODEX_HOME 개념이 없고, `--setting-sources`가 명시적으로
|
||||
문서화된 메커니즘이며, 실측으로 격리를 확인했다).
|
||||
|
||||
## 알려진 제약
|
||||
|
||||
- `claude -p`/`codex exec`는 한 턴을 실행하고 끝난다(대화형 SDK의
|
||||
`canUseTool` 콜백 같은 실시간 승인/차단 훅이 없다). 그래서 이 도구는
|
||||
NDJSON 스트림을 실시간으로 읽다가 질문으로 보이는 이벤트가 나오는 즉시
|
||||
작업 디렉터리를 스냅샷하고 프로세스를 죽이는 방식으로 "질문이 오면 그
|
||||
즉시 측정을 끝낸다"를 흉내 낸다. 완전한 실시간 인터셉트(도구 실행 자체를
|
||||
막는 것)는 아니다.
|
||||
- 텍스트 채널 질문 판정은 휴리스틱이다. 오탐(질문처럼 안 생겼는데 질문인
|
||||
경우, 또는 그 반대)이 있을 수 있으므로 `summary.md`의 질문 원문을 사람이
|
||||
읽고 최종 확인해야 한다.
|
||||
- Windows 전용 경로 해석 이슈를 몇 가지 우회했다(`run-codex.mjs`의
|
||||
주석 참고: codex.cmd → powershell.exe → codex.ps1으로 이어지는 셸
|
||||
릴레이가 공백·한글이 섞인 프롬프트를 깨뜨려서, 가능하면 그 뒤의 네이티브
|
||||
`codex.exe`를 직접 찾아 셸 없이 실행하고, 프롬프트는 인자 대신 표준
|
||||
입력으로 넘긴다). macOS/Linux에서는 `codex`가 보통 네이티브 바이너리라
|
||||
이 문제가 없을 것으로 보이나 이 저장소 환경에서 검증하지는 못했다.
|
||||
Loading…
Add table
Add a link
Reference in a new issue