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`가 보통 네이티브 바이너리라
|
||||||
|
이 문제가 없을 것으로 보이나 이 저장소 환경에서 검증하지는 못했다.
|
||||||
5
build/eval/interview/fixtures/s1-empty/package.json
Normal file
5
build/eval/interview/fixtures/s1-empty/package.json
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
{
|
||||||
|
"name": "eval-scratch-site",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"private": true
|
||||||
|
}
|
||||||
20
build/eval/interview/fixtures/s4-design-md/design.md
Normal file
20
build/eval/interview/fixtures/s4-design-md/design.md
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
# 스택노트 design.md
|
||||||
|
|
||||||
|
## 톤
|
||||||
|
차분하고 신뢰감 있는 톤. 화려한 장식보다 정보 밀도와 가독성을 우선한다.
|
||||||
|
|
||||||
|
## 토큰
|
||||||
|
- `--color-bg`: #0b0f14
|
||||||
|
- `--color-surface`: #121822
|
||||||
|
- `--color-text`: #e6edf3
|
||||||
|
- `--color-accent`: #5eead4
|
||||||
|
- `--font-family`: "Inter", system-ui, sans-serif
|
||||||
|
- `--radius-base`: 8px
|
||||||
|
|
||||||
|
## 브랜드
|
||||||
|
- 제품명: 스택노트 (StackNote)
|
||||||
|
- 로고: 텍스트 워드마크만 사용 (SVG 로고 없음)
|
||||||
|
- 대상: 개발자용 노트 앱 사용자
|
||||||
|
|
||||||
|
## 상태
|
||||||
|
요금제(가격, 플랜 구성)는 아직 design.md에 기록되어 있지 않다.
|
||||||
35
build/eval/interview/fixtures/s4-design-md/index.html
Normal file
35
build/eval/interview/fixtures/s4-design-md/index.html
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<title>스택노트 — 개발자를 위한 노트</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--color-bg: #0b0f14;
|
||||||
|
--color-surface: #121822;
|
||||||
|
--color-text: #e6edf3;
|
||||||
|
--color-accent: #5eead4;
|
||||||
|
--font-family: "Inter", system-ui, sans-serif;
|
||||||
|
--radius-base: 8px;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
background: var(--color-bg);
|
||||||
|
color: var(--color-text);
|
||||||
|
font-family: var(--font-family);
|
||||||
|
}
|
||||||
|
header {
|
||||||
|
padding: 64px 24px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
header h1 { font-size: 2.5rem; margin: 0 0 12px; }
|
||||||
|
header p { opacity: 0.8; margin: 0; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<h1>스택노트</h1>
|
||||||
|
<p>개발자를 위한 빠른 노트 앱</p>
|
||||||
|
</header>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
20
build/eval/interview/fixtures/s6-design-md/design.md
Normal file
20
build/eval/interview/fixtures/s6-design-md/design.md
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
# 꽃집 온라인 스토어 design.md
|
||||||
|
|
||||||
|
## 톤
|
||||||
|
따뜻하고 부드러운 톤. 자연스러운 색감과 손글씨 느낌의 포인트 서체를 쓴다.
|
||||||
|
|
||||||
|
## 토큰
|
||||||
|
- `--color-bg`: #fdf6f0
|
||||||
|
- `--color-surface`: #ffffff
|
||||||
|
- `--color-text`: #2e2620
|
||||||
|
- `--color-accent`: #d97757
|
||||||
|
- `--color-accent-hover`: #d97757
|
||||||
|
- `--font-family`: "Pretendard", system-ui, sans-serif
|
||||||
|
- `--radius-base`: 12px
|
||||||
|
|
||||||
|
## 브랜드
|
||||||
|
- 업종: 동네 꽃집 온라인 주문 사이트
|
||||||
|
- 로고: 텍스트 워드마크
|
||||||
|
|
||||||
|
## 히어로
|
||||||
|
히어로 섹션에 "지금 주문하기" CTA 버튼이 있다. hover 시 `--color-accent-hover`를 그대로 써서 hover 상태가 기본 상태와 색이 거의 같다.
|
||||||
48
build/eval/interview/fixtures/s6-design-md/index.html
Normal file
48
build/eval/interview/fixtures/s6-design-md/index.html
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<title>동네 꽃집 — 온라인 주문</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--color-bg: #fdf6f0;
|
||||||
|
--color-surface: #ffffff;
|
||||||
|
--color-text: #2e2620;
|
||||||
|
--color-accent: #d97757;
|
||||||
|
--color-accent-hover: #d97757;
|
||||||
|
--font-family: "Pretendard", system-ui, sans-serif;
|
||||||
|
--radius-base: 12px;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
background: var(--color-bg);
|
||||||
|
color: var(--color-text);
|
||||||
|
font-family: var(--font-family);
|
||||||
|
}
|
||||||
|
.hero {
|
||||||
|
padding: 96px 24px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.hero h1 { font-size: 2.75rem; margin: 0 0 16px; }
|
||||||
|
.cta {
|
||||||
|
display: inline-block;
|
||||||
|
margin-top: 24px;
|
||||||
|
padding: 14px 28px;
|
||||||
|
background: var(--color-accent);
|
||||||
|
color: #fff;
|
||||||
|
border-radius: var(--radius-base);
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.cta:hover {
|
||||||
|
background: var(--color-accent-hover);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<section class="hero">
|
||||||
|
<h1>오늘의 꽃, 오늘 배달</h1>
|
||||||
|
<a class="cta" href="#order">지금 주문하기</a>
|
||||||
|
</section>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
54
build/eval/interview/fixtures/s7-review/index.html
Normal file
54
build/eval/interview/fixtures/s7-review/index.html
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<title>작은 스튜디오 포트폴리오</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: system-ui, sans-serif;
|
||||||
|
background: #ffffff;
|
||||||
|
color: #111111;
|
||||||
|
}
|
||||||
|
header {
|
||||||
|
padding: 48px 24px;
|
||||||
|
border-bottom: 1px solid #e5e5e5;
|
||||||
|
}
|
||||||
|
header h1 { font-size: 2rem; margin: 0 0 8px; }
|
||||||
|
header p { color: #555555; margin: 0; }
|
||||||
|
main {
|
||||||
|
padding: 32px 24px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
.card {
|
||||||
|
border: 1px solid #e5e5e5;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
.card h2 { font-size: 1.1rem; margin: 0 0 8px; }
|
||||||
|
.card p { color: #555555; font-size: 0.9rem; margin: 0; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<h1>작은 스튜디오</h1>
|
||||||
|
<p>공간 디자인과 인테리어 컨설팅</p>
|
||||||
|
</header>
|
||||||
|
<main>
|
||||||
|
<div class="card">
|
||||||
|
<h2>프로젝트 A</h2>
|
||||||
|
<p>주거 공간 리노베이션.</p>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<h2>프로젝트 B</h2>
|
||||||
|
<p>카페 인테리어.</p>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<h2>프로젝트 C</h2>
|
||||||
|
<p>사무 공간 재배치.</p>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
3
build/eval/interview/fixtures/s8-repo-hint/README.md
Normal file
3
build/eval/interview/fixtures/s8-repo-hint/README.md
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
# 목요일의 화원
|
||||||
|
|
||||||
|
하이엔드 플로럴 스튜디오. 맞춤 부케와 예약 상담을 합니다.
|
||||||
5
build/eval/interview/fixtures/s8-repo-hint/package.json
Normal file
5
build/eval/interview/fixtures/s8-repo-hint/package.json
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
{
|
||||||
|
"name": "thursday-florist",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"private": true
|
||||||
|
}
|
||||||
68
build/eval/interview/lib/fsutil.mjs
Normal file
68
build/eval/interview/lib/fsutil.mjs
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
/** 디렉터리를 재귀적으로 복사한다(대상은 미리 비어 있어야 한다). */
|
||||||
|
export function copyDirRecursive(src, dest) {
|
||||||
|
fs.mkdirSync(dest, { recursive: true });
|
||||||
|
for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
|
||||||
|
const s = path.join(src, entry.name);
|
||||||
|
const d = path.join(dest, entry.name);
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
copyDirRecursive(s, d);
|
||||||
|
} else if (entry.isFile()) {
|
||||||
|
fs.mkdirSync(path.dirname(d), { recursive: true });
|
||||||
|
fs.copyFileSync(s, d);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 작업 디렉터리 전체를 스냅샷한다. { "상대/경로": { size, mtimeMs } } 형태.
|
||||||
|
*/
|
||||||
|
export function snapshotDir(root) {
|
||||||
|
const out = {};
|
||||||
|
function walk(dir) {
|
||||||
|
let entries;
|
||||||
|
try {
|
||||||
|
entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const entry of entries) {
|
||||||
|
const full = path.join(dir, entry.name);
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
walk(full);
|
||||||
|
} else if (entry.isFile()) {
|
||||||
|
const rel = path.relative(root, full).split(path.sep).join("/");
|
||||||
|
try {
|
||||||
|
const st = fs.statSync(full);
|
||||||
|
out[rel] = { size: st.size, mtimeMs: st.mtimeMs };
|
||||||
|
} catch {
|
||||||
|
// 스냅샷 도중 파일이 사라지면(임시 파일 등) 무시한다.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
walk(root);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* before -> after 스냅샷을 비교해 생성·수정된 파일 목록과 개수를 낸다.
|
||||||
|
* 삭제된 파일은 개수에 넣지 않는다(설계상 "생성·수정"만 센다).
|
||||||
|
*/
|
||||||
|
export function diffSnapshots(before, after) {
|
||||||
|
const added = [];
|
||||||
|
const modified = [];
|
||||||
|
for (const rel of Object.keys(after)) {
|
||||||
|
const b = before[rel];
|
||||||
|
const a = after[rel];
|
||||||
|
if (!b) {
|
||||||
|
added.push(rel);
|
||||||
|
} else if (b.size !== a.size || b.mtimeMs !== a.mtimeMs) {
|
||||||
|
modified.push(rel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const removed = Object.keys(before).filter((rel) => !(rel in after));
|
||||||
|
return { added, modified, removed, count: added.length + modified.length };
|
||||||
|
}
|
||||||
44
build/eval/interview/lib/text-heuristic.mjs
Normal file
44
build/eval/interview/lib/text-heuristic.mjs
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
// 텍스트 채널 질문 판정 휴리스틱.
|
||||||
|
// 구조화 질문 도구(AskUserQuestion 등)가 없는 하네스에서 에이전트가 평문으로
|
||||||
|
// 사용자에게 되묻는지를 1차로 스크리닝한다. 원문은 항상 결과에 같이 남기고,
|
||||||
|
// 최종 판정은 오케스트레이터가 원문을 직접 읽어 내린다(이 함수는 신호 수집용).
|
||||||
|
|
||||||
|
const REQUEST_PHRASES = [
|
||||||
|
/알려\s*주세요/,
|
||||||
|
/말씀해\s*주세요/,
|
||||||
|
/선택해\s*주세요/,
|
||||||
|
/답변해\s*주세요/,
|
||||||
|
/알려\s*줄래/,
|
||||||
|
/알려\s*줘/,
|
||||||
|
/선택해\s*줘/,
|
||||||
|
/여쭤/,
|
||||||
|
/궁금합니다/,
|
||||||
|
/어떤.{0,12}(원하시|하시나요|인가요|할까요|드릴까요)/,
|
||||||
|
/어느.{0,12}(인가요|하시나요|할까요)/,
|
||||||
|
/무엇.{0,12}(인가요|일까요)/,
|
||||||
|
/확인.{0,6}(부탁|해주세요|해줘)/,
|
||||||
|
];
|
||||||
|
|
||||||
|
const NUMBERED_OPTION_LINE = /(^|\n)\s*(?:[0-9]{1,2}[.)]|[A-Ea-e][.)])\s+\S/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} text 에이전트가 낸 텍스트 블록(들)을 합친 원문
|
||||||
|
* @returns {{isQuestion: boolean, questionMarks: number, optionLines: number, phraseHits: string[]}}
|
||||||
|
*/
|
||||||
|
export function scoreQuestionText(text) {
|
||||||
|
const s = String(text || "");
|
||||||
|
const questionMarks = (s.match(/[??]/g) || []).length;
|
||||||
|
const optionLineMatches = s.match(new RegExp(NUMBERED_OPTION_LINE, "gm")) || [];
|
||||||
|
const optionLines = optionLineMatches.length;
|
||||||
|
const phraseHits = REQUEST_PHRASES.filter((re) => re.test(s)).map((re) => re.source);
|
||||||
|
|
||||||
|
// 질문으로 볼 신호: 물음표가 1개 이상 있거나, 요청형 한국어 어미가 있으면서
|
||||||
|
// 선택지 나열(번호/알파벳) 패턴이 함께 있으면 더 강한 신호로 취급한다.
|
||||||
|
const hasQuestionMark = questionMarks >= 1;
|
||||||
|
const hasRequestPhrase = phraseHits.length >= 1;
|
||||||
|
const hasOptionList = optionLines >= 1;
|
||||||
|
|
||||||
|
const isQuestion = hasQuestionMark || hasRequestPhrase || (hasOptionList && s.length < 4000);
|
||||||
|
|
||||||
|
return { isQuestion, questionMarks, optionLines, phraseHits };
|
||||||
|
}
|
||||||
218
build/eval/interview/run-claude-sdk.mjs
Normal file
218
build/eval/interview/run-claude-sdk.mjs
Normal file
|
|
@ -0,0 +1,218 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
// Claude Agent SDK 러너(v2) — designpaca 인터뷰 게이트 평가.
|
||||||
|
//
|
||||||
|
// 기존 run-claude.mjs(`claude -p` CLI 직접 호출)는 남겨 둔다. 이 러너는
|
||||||
|
// 오케스트레이터 지시로 추가한 SDK 버전이다: 실측으로 확인했듯 이 환경에서는
|
||||||
|
// @anthropic-ai/claude-agent-sdk 의 query() 가 이미 로그인된 `claude` CLI를
|
||||||
|
// 서브프로세스로 띄워 그대로 동작한다(공식 quickstart의 ANTHROPIC_API_KEY
|
||||||
|
// 요구사항과 별개로, CLI 로그인 세션을 그대로 탄다 — apiKeySource: "none").
|
||||||
|
//
|
||||||
|
// canUseTool 로 AskUserQuestion 호출을 실시간으로 가로챈다: 호출이 오면
|
||||||
|
// 질문 JSON을 기록하고 askChannel="tool"로 표시한 뒤 deny 하고 즉시
|
||||||
|
// abortController.abort() 한다("평가 종료"). Write/Edit/NotebookEdit 등은
|
||||||
|
// 허용하되 호출을 기록한다. 텍스트로만 묻는 경우는 run-claude.mjs와 같은
|
||||||
|
// 휴리스틱으로 감지해서 같은 방식으로 abort 한다.
|
||||||
|
//
|
||||||
|
// 설치(1회):
|
||||||
|
// cd outputs/eval/deps && npm init -y && npm install @anthropic-ai/claude-agent-sdk
|
||||||
|
// (저장소 의존성을 늘리지 않으려고 outputs/eval/deps/ 에 따로 설치한다.)
|
||||||
|
//
|
||||||
|
// 단독 실행:
|
||||||
|
// node build/eval/interview/run-claude-sdk.mjs \
|
||||||
|
// --workdir <절대경로> --scenario S1 --prompt "..." \
|
||||||
|
// --model sonnet --permission-mode auto --out result.json --log raw.jsonl
|
||||||
|
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||||
|
import { snapshotDir, diffSnapshots } from "./lib/fsutil.mjs";
|
||||||
|
import { scoreQuestionText } from "./lib/text-heuristic.mjs";
|
||||||
|
|
||||||
|
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const REPO_ROOT = path.resolve(HERE, "..", "..", "..");
|
||||||
|
const SDK_ENTRY = path.join(REPO_ROOT, "outputs", "eval", "deps", "node_modules", "@anthropic-ai", "claude-agent-sdk", "sdk.mjs");
|
||||||
|
|
||||||
|
const WRITE_LIKE_TOOLS = new Set(["Write", "Edit", "MultiEdit", "NotebookEdit"]);
|
||||||
|
|
||||||
|
function parseArgs(argv) {
|
||||||
|
const out = {};
|
||||||
|
for (let i = 0; i < argv.length; i++) {
|
||||||
|
const a = argv[i];
|
||||||
|
if (a.startsWith("--")) {
|
||||||
|
const key = a.slice(2);
|
||||||
|
const next = argv[i + 1];
|
||||||
|
if (next === undefined || next.startsWith("--")) {
|
||||||
|
out[key] = true;
|
||||||
|
} else {
|
||||||
|
out[key] = next;
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const args = parseArgs(process.argv.slice(2));
|
||||||
|
const workdir = args.workdir;
|
||||||
|
const scenario = args.scenario || "unknown";
|
||||||
|
const prompt = args.prompt;
|
||||||
|
const model = args.model || "sonnet";
|
||||||
|
const permissionMode = args["permission-mode"] || "default"; // "auto" | "default"
|
||||||
|
const timeoutMs = Number(args["timeout-ms"] || 300000);
|
||||||
|
const outPath = args.out;
|
||||||
|
const logPath = args.log;
|
||||||
|
|
||||||
|
if (!workdir || !prompt || !outPath) {
|
||||||
|
console.error("사용법: run-claude-sdk.mjs --workdir <dir> --scenario <id> --prompt <text> --out <result.json> [--log <raw.jsonl>] [--model <m>] [--permission-mode auto|default] [--timeout-ms <ms>]");
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!fs.existsSync(SDK_ENTRY)) {
|
||||||
|
console.error(`SDK가 설치되어 있지 않다: ${SDK_ENTRY}\ncd outputs/eval/deps && npm install @anthropic-ai/claude-agent-sdk 로 먼저 설치할 것.`);
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { query } = await import(pathToFileURL(SDK_ENTRY).href);
|
||||||
|
|
||||||
|
const snapshotBefore = snapshotDir(workdir);
|
||||||
|
const logStream = logPath ? fs.createWriteStream(logPath, { flags: "a" }) : null;
|
||||||
|
const startedAt = Date.now();
|
||||||
|
|
||||||
|
const result = {
|
||||||
|
scenario,
|
||||||
|
runner: "claude-sdk",
|
||||||
|
model: null,
|
||||||
|
modelRequested: model,
|
||||||
|
permissionMode,
|
||||||
|
permissionModeRejected: false,
|
||||||
|
asked: false,
|
||||||
|
askChannel: null,
|
||||||
|
questionText: null,
|
||||||
|
wroteBeforeAnswer: null,
|
||||||
|
firstVisibleAction: null,
|
||||||
|
finalMessageText: null,
|
||||||
|
toolsUsed: [],
|
||||||
|
initTools: null,
|
||||||
|
askUserQuestionAvailable: null,
|
||||||
|
initSkills: null,
|
||||||
|
initPlugins: null,
|
||||||
|
claudeCodeVersion: null,
|
||||||
|
apiKeySource: null,
|
||||||
|
durationMs: null,
|
||||||
|
killedReason: null,
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
let finished = false;
|
||||||
|
const overallTimeout = setTimeout(() => {
|
||||||
|
if (!finished) {
|
||||||
|
result.killedReason = "timeout";
|
||||||
|
controller.abort();
|
||||||
|
}
|
||||||
|
}, timeoutMs);
|
||||||
|
|
||||||
|
function markAskedIfNeeded(channel, text) {
|
||||||
|
if (result.asked) return;
|
||||||
|
result.asked = true;
|
||||||
|
result.askChannel = channel;
|
||||||
|
result.questionText = text;
|
||||||
|
const after = snapshotDir(workdir);
|
||||||
|
result.wroteBeforeAnswer = diffSnapshots(snapshotBefore, after).count;
|
||||||
|
result.killedReason = result.killedReason || "asked";
|
||||||
|
controller.abort();
|
||||||
|
}
|
||||||
|
|
||||||
|
const canUseTool = async (toolName, input) => {
|
||||||
|
if (logStream) logStream.write(JSON.stringify({ type: "canUseTool", toolName, input }) + "\n");
|
||||||
|
result.toolsUsed.push(toolName);
|
||||||
|
if (!result.firstVisibleAction) {
|
||||||
|
result.firstVisibleAction = `tool:${toolName}`;
|
||||||
|
}
|
||||||
|
if (toolName === "AskUserQuestion") {
|
||||||
|
markAskedIfNeeded("tool", JSON.stringify(input));
|
||||||
|
return { behavior: "deny", message: "평가 종료" };
|
||||||
|
}
|
||||||
|
// Write/Edit 등은 허용하되 기록만 한다(설계: 기록하고 허용).
|
||||||
|
return { behavior: "allow", updatedInput: input };
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
for await (const message of query({
|
||||||
|
prompt,
|
||||||
|
options: {
|
||||||
|
cwd: workdir,
|
||||||
|
model,
|
||||||
|
permissionMode,
|
||||||
|
settingSources: ["project"],
|
||||||
|
systemPrompt: { type: "preset", preset: "claude_code" },
|
||||||
|
abortController: controller,
|
||||||
|
maxTurns: 15,
|
||||||
|
canUseTool,
|
||||||
|
},
|
||||||
|
})) {
|
||||||
|
if (logStream) logStream.write(JSON.stringify(message).slice(0, 20000) + "\n");
|
||||||
|
|
||||||
|
if (message.type === "system" && message.subtype === "init") {
|
||||||
|
result.model = message.model || null;
|
||||||
|
result.initTools = message.tools || null;
|
||||||
|
result.askUserQuestionAvailable = Array.isArray(message.tools) ? message.tools.includes("AskUserQuestion") : null;
|
||||||
|
result.initSkills = message.skills || null;
|
||||||
|
result.initPlugins = message.plugins || null;
|
||||||
|
result.claudeCodeVersion = message.claude_code_version || null;
|
||||||
|
result.apiKeySource = message.apiKeySource || null;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.type === "assistant") {
|
||||||
|
const content = (message.message && message.message.content) || [];
|
||||||
|
for (const block of content) {
|
||||||
|
if (block.type === "text" && block.text) {
|
||||||
|
if (!result.firstVisibleAction) {
|
||||||
|
result.firstVisibleAction = `text:${block.text.slice(0, 120)}`;
|
||||||
|
}
|
||||||
|
const score = scoreQuestionText(block.text);
|
||||||
|
if (score.isQuestion) {
|
||||||
|
markAskedIfNeeded("text", block.text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.type === "result") {
|
||||||
|
result.finalMessageText = message.result || null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
const msg = String(e && e.message ? e.message : e);
|
||||||
|
const isAbort = e && (e.name === "AbortError" || /abort/i.test(msg));
|
||||||
|
if (!isAbort) {
|
||||||
|
if (/permission[_-]?mode/i.test(msg) || /auto/i.test(msg)) {
|
||||||
|
result.permissionModeRejected = true;
|
||||||
|
}
|
||||||
|
result.error = msg;
|
||||||
|
result.errorStack = e && e.stack ? String(e.stack).slice(0, 2000) : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
finished = true;
|
||||||
|
clearTimeout(overallTimeout);
|
||||||
|
if (logStream) logStream.end();
|
||||||
|
|
||||||
|
if (result.wroteBeforeAnswer === null) {
|
||||||
|
const after = snapshotDir(workdir);
|
||||||
|
result.wroteBeforeAnswer = diffSnapshots(snapshotBefore, after).count;
|
||||||
|
}
|
||||||
|
result.durationMs = Date.now() - startedAt;
|
||||||
|
|
||||||
|
fs.mkdirSync(path.dirname(outPath), { recursive: true });
|
||||||
|
fs.writeFileSync(outPath, JSON.stringify(result, null, 2));
|
||||||
|
console.log(JSON.stringify(result));
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error("run-claude-sdk.mjs 실패:", err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
256
build/eval/interview/run-claude.mjs
Normal file
256
build/eval/interview/run-claude.mjs
Normal file
|
|
@ -0,0 +1,256 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
// Claude Code CLI 러너 — designpaca 인터뷰 게이트 평가.
|
||||||
|
//
|
||||||
|
// Claude Agent SDK(@anthropic-ai/claude-agent-sdk)는 공식 quickstart 기준
|
||||||
|
// ANTHROPIC_API_KEY 인증을 요구한다(code.claude.com/docs/en/agent-sdk/quickstart).
|
||||||
|
// 이 저장소가 도는 환경에는 그 키가 없으므로, 이미 로그인된 `claude` CLI를
|
||||||
|
// 비대화형(-p)으로 직접 호출한다. `-p` 모드는 한 턴을 실행하고 종료하므로
|
||||||
|
// SDK의 canUseTool 훅 없이도 "질문이 오면 그 시점에서 측정을 끝낸다"를
|
||||||
|
// stdout NDJSON을 실시간으로 읽으면서 흉내 낸다: 질문으로 보이는 이벤트가
|
||||||
|
// 나오는 즉시 작업 디렉터리를 스냅샷하고 프로세스를 죽인다.
|
||||||
|
//
|
||||||
|
// 격리: --setting-sources project 로 사용자(user)·로컬(local) 설정을 빼고
|
||||||
|
// 프로젝트 설정만 읽는다. 이 작업 디렉터리에는 우리가 넣은
|
||||||
|
// .claude/skills/designpaca 하나만 있어야 하므로, 전역 ~/.claude/CLAUDE.md와
|
||||||
|
// 전역에 설치된 designpaca가 섞이지 않는다. --strict-mcp-config로 잡다한
|
||||||
|
// MCP 서버 연결도 끊는다.
|
||||||
|
//
|
||||||
|
// 단독 실행:
|
||||||
|
// node build/eval/interview/run-claude.mjs \
|
||||||
|
// --workdir <절대경로> --scenario S1 --prompt "꽃집 홍보 사이트 하나 만들어줘." \
|
||||||
|
// --model sonnet --permission-mode default --out result.json --log raw.ndjson
|
||||||
|
|
||||||
|
import { spawn, execFileSync } from "node:child_process";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import readline from "node:readline";
|
||||||
|
import { snapshotDir, diffSnapshots } from "./lib/fsutil.mjs";
|
||||||
|
import { scoreQuestionText } from "./lib/text-heuristic.mjs";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Windows 에서 `spawn(..., {shell:true})`는 target이 .exe라도 항상 cmd.exe를
|
||||||
|
* 한 겹 거친다. cmd.exe의 명령줄 재파싱이 공백·따옴표·일부 영문 대문자
|
||||||
|
* 조합이 섞인 한국어 프롬프트를 실측으로 깨뜨렸다(예: "개인 포트폴리오...
|
||||||
|
* UX 디자이너야." 가 "개인" 한 단어로 잘려 들어간 사례를 baseline 실행에서
|
||||||
|
* 발견했다). `claude` 는 npm .cmd 셈이 아니라 네이티브 .exe라서, `where`로
|
||||||
|
* 실제 .exe 경로를 찾아 셸 없이(shell:false) 직접 spawn하면 이 문제를 피한다.
|
||||||
|
*/
|
||||||
|
function resolveClaudeBinaryWindows() {
|
||||||
|
try {
|
||||||
|
const out = execFileSync("where", ["claude"], { encoding: "utf8" });
|
||||||
|
const exe = out.split(/\r?\n/).map((l) => l.trim()).find((l) => l.toLowerCase().endsWith(".exe"));
|
||||||
|
return exe || null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseArgs(argv) {
|
||||||
|
const out = {};
|
||||||
|
for (let i = 0; i < argv.length; i++) {
|
||||||
|
const a = argv[i];
|
||||||
|
if (a.startsWith("--")) {
|
||||||
|
const key = a.slice(2);
|
||||||
|
const next = argv[i + 1];
|
||||||
|
if (next === undefined || next.startsWith("--")) {
|
||||||
|
out[key] = true;
|
||||||
|
} else {
|
||||||
|
out[key] = next;
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
const WRITE_LIKE_TOOLS = new Set(["Write", "Edit", "MultiEdit", "NotebookEdit"]);
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const args = parseArgs(process.argv.slice(2));
|
||||||
|
const workdir = args.workdir;
|
||||||
|
const scenario = args.scenario || "unknown";
|
||||||
|
const prompt = args.prompt;
|
||||||
|
const model = args.model || "sonnet";
|
||||||
|
const permissionMode = args["permission-mode"] || null; // 생략하면 claude 기본값(manual)
|
||||||
|
const timeoutMs = Number(args["timeout-ms"] || 300000);
|
||||||
|
const outPath = args.out;
|
||||||
|
const logPath = args.log;
|
||||||
|
|
||||||
|
if (!workdir || !prompt || !outPath) {
|
||||||
|
console.error("사용법: run-claude.mjs --workdir <dir> --scenario <id> --prompt <text> --out <result.json> [--log <raw.ndjson>] [--model <m>] [--permission-mode <mode>] [--timeout-ms <ms>]");
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
const claudeArgs = [
|
||||||
|
"-p",
|
||||||
|
prompt,
|
||||||
|
"--output-format",
|
||||||
|
"stream-json",
|
||||||
|
"--verbose",
|
||||||
|
"--setting-sources",
|
||||||
|
"project",
|
||||||
|
"--strict-mcp-config",
|
||||||
|
"--model",
|
||||||
|
model,
|
||||||
|
];
|
||||||
|
if (permissionMode) {
|
||||||
|
claudeArgs.push("--permission-mode", permissionMode);
|
||||||
|
}
|
||||||
|
|
||||||
|
const snapshotBefore = snapshotDir(workdir);
|
||||||
|
|
||||||
|
const result = {
|
||||||
|
scenario,
|
||||||
|
runner: "claude",
|
||||||
|
model,
|
||||||
|
permissionMode: permissionMode || "manual(default)",
|
||||||
|
asked: false,
|
||||||
|
askChannel: null,
|
||||||
|
questionText: null,
|
||||||
|
wroteBeforeAnswer: null,
|
||||||
|
firstVisibleAction: null,
|
||||||
|
finalMessageText: null,
|
||||||
|
toolsUsed: [],
|
||||||
|
initTools: null,
|
||||||
|
durationMs: null,
|
||||||
|
killedReason: null,
|
||||||
|
exitCode: null,
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const logStream = logPath ? fs.createWriteStream(logPath, { flags: "a" }) : null;
|
||||||
|
const startedAt = Date.now();
|
||||||
|
|
||||||
|
const isWin = process.platform === "win32";
|
||||||
|
let claudeBin = "claude";
|
||||||
|
let useShell = isWin;
|
||||||
|
if (isWin) {
|
||||||
|
const resolved = resolveClaudeBinaryWindows();
|
||||||
|
if (resolved) {
|
||||||
|
claudeBin = resolved;
|
||||||
|
useShell = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result.claudeBinaryUsed = claudeBin;
|
||||||
|
|
||||||
|
const child = spawn(claudeBin, claudeArgs, {
|
||||||
|
cwd: workdir,
|
||||||
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
|
shell: useShell,
|
||||||
|
});
|
||||||
|
|
||||||
|
let killedByUs = false;
|
||||||
|
function killChild(reason) {
|
||||||
|
if (killedByUs || child.killed) return;
|
||||||
|
killedByUs = true;
|
||||||
|
result.killedReason = reason;
|
||||||
|
try {
|
||||||
|
child.kill("SIGTERM");
|
||||||
|
} catch {
|
||||||
|
// 이미 종료된 경우 무시
|
||||||
|
}
|
||||||
|
setTimeout(() => {
|
||||||
|
try {
|
||||||
|
if (!child.killed) child.kill("SIGKILL");
|
||||||
|
} catch {
|
||||||
|
// 무시
|
||||||
|
}
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
const overallTimeout = setTimeout(() => killChild("timeout"), timeoutMs);
|
||||||
|
|
||||||
|
function markAskedIfNeeded(channel, text) {
|
||||||
|
if (result.asked) return;
|
||||||
|
result.asked = true;
|
||||||
|
result.askChannel = channel;
|
||||||
|
result.questionText = text;
|
||||||
|
const after = snapshotDir(workdir);
|
||||||
|
result.wroteBeforeAnswer = diffSnapshots(snapshotBefore, after).count;
|
||||||
|
killChild("asked");
|
||||||
|
}
|
||||||
|
|
||||||
|
let stderrBuf = "";
|
||||||
|
child.stderr.on("data", (chunk) => {
|
||||||
|
stderrBuf += chunk.toString();
|
||||||
|
});
|
||||||
|
|
||||||
|
const rl = readline.createInterface({ input: child.stdout, crlfDelay: Infinity });
|
||||||
|
rl.on("line", (line) => {
|
||||||
|
if (logStream) logStream.write(line + "\n");
|
||||||
|
if (!line.trim()) return;
|
||||||
|
let evt;
|
||||||
|
try {
|
||||||
|
evt = JSON.parse(line);
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (evt.type === "system" && evt.subtype === "init") {
|
||||||
|
result.initTools = evt.tools || null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (evt.type === "assistant") {
|
||||||
|
const content = (evt.message && evt.message.content) || [];
|
||||||
|
for (const block of content) {
|
||||||
|
if (block.type === "text" && block.text) {
|
||||||
|
if (!result.firstVisibleAction) {
|
||||||
|
result.firstVisibleAction = `text:${block.text.slice(0, 120)}`;
|
||||||
|
}
|
||||||
|
const score = scoreQuestionText(block.text);
|
||||||
|
if (score.isQuestion) {
|
||||||
|
markAskedIfNeeded("text", block.text);
|
||||||
|
}
|
||||||
|
} else if (block.type === "tool_use") {
|
||||||
|
result.toolsUsed.push(block.name);
|
||||||
|
if (!result.firstVisibleAction) {
|
||||||
|
result.firstVisibleAction = `tool:${block.name}`;
|
||||||
|
}
|
||||||
|
if (block.name === "AskUserQuestion") {
|
||||||
|
const qText = JSON.stringify(block.input);
|
||||||
|
markAskedIfNeeded("tool", qText);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (evt.type === "result") {
|
||||||
|
result.finalMessageText = evt.result || null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await new Promise((resolve) => {
|
||||||
|
child.on("close", (code) => {
|
||||||
|
result.exitCode = code;
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
child.on("error", (err) => {
|
||||||
|
result.error = String(err && err.message ? err.message : err);
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
clearTimeout(overallTimeout);
|
||||||
|
if (logStream) logStream.end();
|
||||||
|
|
||||||
|
if (result.wroteBeforeAnswer === null) {
|
||||||
|
// 끝까지 질문이 없었던 경우: 전체 실행 동안의 변경 개수를 기록한다.
|
||||||
|
const after = snapshotDir(workdir);
|
||||||
|
result.wroteBeforeAnswer = diffSnapshots(snapshotBefore, after).count;
|
||||||
|
}
|
||||||
|
result.durationMs = Date.now() - startedAt;
|
||||||
|
if (!result.error && stderrBuf.trim()) {
|
||||||
|
result.stderrTail = stderrBuf.slice(-2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.mkdirSync(path.dirname(outPath), { recursive: true });
|
||||||
|
fs.writeFileSync(outPath, JSON.stringify(result, null, 2));
|
||||||
|
console.log(JSON.stringify(result));
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error("run-claude.mjs 실패:", err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
384
build/eval/interview/run-codex.mjs
Normal file
384
build/eval/interview/run-codex.mjs
Normal file
|
|
@ -0,0 +1,384 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
// Codex CLI 러너 — designpaca 인터뷰 게이트 평가.
|
||||||
|
//
|
||||||
|
// `codex exec --json` 비대화형 모드에서는 request_user_input 이
|
||||||
|
// "not supported in exec mode" 로 거부되므로, 질문은 항상 평문
|
||||||
|
// (agent_message 아이템)으로만 나온다. 구조화 질문 채널은 없다.
|
||||||
|
//
|
||||||
|
// 격리:
|
||||||
|
// - CODEX_HOME 을 이 실행 전용 임시 디렉터리로 돌린다. 인증은 사용자의
|
||||||
|
// 실제 ~/.codex/auth.json 을 그 임시 CODEX_HOME 에 복사해서만 쓰고(원본은
|
||||||
|
// 읽기만), 실행이 끝나면 임시 복사본을 지운다.
|
||||||
|
// - HOME/USERPROFILE 도 같이 돌린다. 단, 이 Codex 빌드(0.153.4, Windows)는
|
||||||
|
// `~/.agents/skills` 사용자 스코프 스킬 루트를 홈 디렉터리 크레이트로
|
||||||
|
// 해석하면서 HOME/USERPROFILE 환경변수 재정의를 무시하는 것을 실측으로
|
||||||
|
// 확인했다(같은 이름의 실제 설치 스킬이 격리 실행에서도 그대로 보였다).
|
||||||
|
// CODEX_HOME 은 정상적으로 재정의를 따른다(codex doctor 로 확인).
|
||||||
|
// 이 스크립트는 실행 전 "실제" 사용자 홈의 ~/.agents/skills, ~/.codex/skills
|
||||||
|
// 를 스캔해 그 안의 스킬 이름을 전부 격리된 CODEX_HOME/config.toml의
|
||||||
|
// [[skills.config]] 규칙으로 끄고, 우리가 작업 디렉터리에 넣은 SKILL.md
|
||||||
|
// 경로만 다시 켜는 방식으로 이름 충돌(예: 실제 설치된 "designpaca" v0.12.0이
|
||||||
|
// 같이 잡히는 것)을 막는다.
|
||||||
|
// 그 밖의 무관한 스킬 노출 자체는 이 방식으로도 완전히 막지 못한다
|
||||||
|
// (README 의 "알려진 한계" 참고).
|
||||||
|
// - fresh CODEX_HOME에서는 workspace-write 샌드박스의 exec_command가 전부
|
||||||
|
// "blocked by policy"로 거부되던 문제가 있었다(pwd조차 실패). 실측으로
|
||||||
|
// 원인을 찾았다: 사용자의 실제 ~/.codex/config.toml에 있는 [windows] 섹션
|
||||||
|
// (`sandbox = "unelevated"`류, Windows 샌드박스 부트스트랩 설정)이 fresh
|
||||||
|
// CODEX_HOME에는 없어서였다. 이 섹션은 인증·토큰·계정 값이 없어 읽기 전용으로
|
||||||
|
// 그대로 복제해도 안전하므로, 실행마다 실제 ~/.codex/config.toml에서 이
|
||||||
|
// 섹션만 추출해 격리된 CODEX_HOME/config.toml에 덧붙인다
|
||||||
|
// (extractWindowsSectionFromRealConfig). 원본은 절대 쓰지 않는다.
|
||||||
|
//
|
||||||
|
// 단독 실행:
|
||||||
|
// node build/eval/interview/run-codex.mjs \
|
||||||
|
// --workdir <절대경로> --scenario S1 --prompt "..." \
|
||||||
|
// --skill-path <워크dir 안의 SKILL.md 절대경로> \
|
||||||
|
// --out result.json --log raw.jsonl
|
||||||
|
|
||||||
|
import { spawn, execFileSync } from "node:child_process";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import readline from "node:readline";
|
||||||
|
import { snapshotDir, diffSnapshots } from "./lib/fsutil.mjs";
|
||||||
|
import { scoreQuestionText } from "./lib/text-heuristic.mjs";
|
||||||
|
|
||||||
|
function parseArgs(argv) {
|
||||||
|
const out = {};
|
||||||
|
for (let i = 0; i < argv.length; i++) {
|
||||||
|
const a = argv[i];
|
||||||
|
if (a.startsWith("--")) {
|
||||||
|
const key = a.slice(2);
|
||||||
|
const next = argv[i + 1];
|
||||||
|
if (next === undefined || next.startsWith("--")) {
|
||||||
|
out[key] = true;
|
||||||
|
} else {
|
||||||
|
out[key] = next;
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function tomlString(s) {
|
||||||
|
return `"${String(s).replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 실제 사용자 홈의 .agents/skills, .codex/skills 안 스킬 이름을 전부 모은다(SKILL.md frontmatter의 name:). */
|
||||||
|
function collectRealSkillNames(realHome) {
|
||||||
|
const names = new Set();
|
||||||
|
for (const sub of [path.join(realHome, ".agents", "skills"), path.join(realHome, ".codex", "skills")]) {
|
||||||
|
let entries;
|
||||||
|
try {
|
||||||
|
entries = fs.readdirSync(sub, { withFileTypes: true });
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (!entry.isDirectory()) continue;
|
||||||
|
const skillMd = path.join(sub, entry.name, "SKILL.md");
|
||||||
|
try {
|
||||||
|
const text = fs.readFileSync(skillMd, "utf8");
|
||||||
|
const m = text.match(/^name:\s*(.+?)\s*$/m);
|
||||||
|
if (m) names.add(m[1].trim());
|
||||||
|
} catch {
|
||||||
|
// SKILL.md 없으면 무시
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...names];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 격리 가드용 skills.config TOML 조각을 만든다. 우리 skillPath 는 이름이 겹쳐도 다시 켠다.
|
||||||
|
* -c CLI 플래그로 넘기면 Windows shell 인용 문제(중첩 따옴표가 깨짐)가 있어
|
||||||
|
* CODEX_HOME/config.toml 파일에 직접 쓴다([[skills.config]] 배열-테이블 형식).
|
||||||
|
*/
|
||||||
|
function buildSkillIsolationToml(realHome, ourSkillPath) {
|
||||||
|
const names = collectRealSkillNames(realHome);
|
||||||
|
const blocks = names.map((n) => `[[skills.config]]\nname = ${tomlString(n)}\nenabled = false\n`);
|
||||||
|
blocks.push(`[[skills.config]]\npath = ${tomlString(ourSkillPath)}\nenabled = true\n`);
|
||||||
|
return blocks.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 사용자의 실제 ~/.codex/config.toml 에서 [windows] 섹션만 읽기 전용으로 추출한다.
|
||||||
|
* fresh(격리된) CODEX_HOME 에서 workspace-write 샌드박스의 모든 exec_command가
|
||||||
|
* "blocked by policy"로 거부되던 문제가, 이 섹션(`sandbox = "unelevated"`류 Windows
|
||||||
|
* 샌드박스 부트스트랩 설정)이 없어서였음을 실측으로 확인했다(재현: fresh CODEX_HOME +
|
||||||
|
* 이 섹션 추가 -> pwd/Get-Content 정상 동작). 이 섹션에는 인증·토큰·계정 값이 없다
|
||||||
|
* (실제 값은 `sandbox = "unelevated"` 한 줄 정도). 원본 ~/.codex/config.toml 은
|
||||||
|
* 절대 쓰지 않는다 — 읽기만 한다.
|
||||||
|
*/
|
||||||
|
function extractWindowsSectionFromRealConfig(realHome) {
|
||||||
|
const cfgPath = path.join(realHome, ".codex", "config.toml");
|
||||||
|
let text;
|
||||||
|
try {
|
||||||
|
text = fs.readFileSync(cfgPath, "utf8");
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const lines = text.split(/\r?\n/);
|
||||||
|
let start = -1;
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
if (/^\[windows\]\s*$/.test(lines[i])) {
|
||||||
|
start = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (start === -1) return null;
|
||||||
|
let end = lines.length;
|
||||||
|
for (let i = start + 1; i < lines.length; i++) {
|
||||||
|
if (/^\[/.test(lines[i])) {
|
||||||
|
end = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const section = lines.slice(start, end).join("\n").trim();
|
||||||
|
return section ? section + "\n" : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Windows 에서 npm이 깐 `codex` 는 codex.cmd -> powershell.exe -File codex.ps1 로 이어지는
|
||||||
|
* 배치/파워셸 릴레이라, 공백과 한글이 섞인 프롬프트가 인자·stdin 양쪽에서 깨지는 것을
|
||||||
|
* 실측으로 확인했다. 가능하면 그 뒤에 있는 진짜 네이티브 codex.exe를 직접 찾아
|
||||||
|
* 셸을 거치지 않고 그대로 실행한다(찾지 못하면 codex.cmd 경로로 폴백한다).
|
||||||
|
*/
|
||||||
|
function resolveCodexBinaryWindows() {
|
||||||
|
try {
|
||||||
|
const globalRoot = execFileSync("npm", ["root", "-g"], { encoding: "utf8", shell: true }).trim();
|
||||||
|
const scopeDir = path.join(globalRoot, "@openai", "codex", "node_modules", "@openai");
|
||||||
|
const entries = fs.readdirSync(scopeDir, { withFileTypes: true }).filter((e) => e.isDirectory() && e.name.startsWith("codex-"));
|
||||||
|
for (const e of entries) {
|
||||||
|
const vendorDir = path.join(scopeDir, e.name, "vendor");
|
||||||
|
if (!fs.existsSync(vendorDir)) continue;
|
||||||
|
for (const triple of fs.readdirSync(vendorDir)) {
|
||||||
|
const exe = path.join(vendorDir, triple, "bin", "codex.exe");
|
||||||
|
if (fs.existsSync(exe)) return exe;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const args = parseArgs(process.argv.slice(2));
|
||||||
|
const workdir = args.workdir;
|
||||||
|
const scenario = args.scenario || "unknown";
|
||||||
|
const prompt = args.prompt;
|
||||||
|
const model = args.model || null;
|
||||||
|
const timeoutMs = Number(args["timeout-ms"] || 300000);
|
||||||
|
const outPath = args.out;
|
||||||
|
const logPath = args.log;
|
||||||
|
const skillPath = args["skill-path"]; // workdir 안 .agents/skills/designpaca/SKILL.md
|
||||||
|
|
||||||
|
if (!workdir || !prompt || !outPath || !skillPath) {
|
||||||
|
console.error("사용법: run-codex.mjs --workdir <dir> --scenario <id> --prompt <text> --skill-path <SKILL.md 경로> --out <result.json> [--log <raw.jsonl>] [--model <m>] [--timeout-ms <ms>]");
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
const realHome = os.homedir();
|
||||||
|
const isolationRoot = path.join(path.dirname(workdir), "_codex-isolation");
|
||||||
|
const isolatedHome = path.join(isolationRoot, "home");
|
||||||
|
const isolatedCodexHome = path.join(isolationRoot, "codexhome");
|
||||||
|
fs.mkdirSync(isolatedHome, { recursive: true });
|
||||||
|
fs.mkdirSync(isolatedCodexHome, { recursive: true });
|
||||||
|
|
||||||
|
const realAuthPath = path.join(realHome, ".codex", "auth.json");
|
||||||
|
const isolatedAuthPath = path.join(isolatedCodexHome, "auth.json");
|
||||||
|
let authCopied = false;
|
||||||
|
if (fs.existsSync(realAuthPath)) {
|
||||||
|
fs.copyFileSync(realAuthPath, isolatedAuthPath);
|
||||||
|
authCopied = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const windowsSection = extractWindowsSectionFromRealConfig(realHome);
|
||||||
|
const isolationToml = buildSkillIsolationToml(realHome, skillPath) + (windowsSection ? "\n" + windowsSection : "");
|
||||||
|
fs.writeFileSync(path.join(isolatedCodexHome, "config.toml"), isolationToml);
|
||||||
|
|
||||||
|
const codexArgs = [
|
||||||
|
"exec",
|
||||||
|
"--json",
|
||||||
|
"--skip-git-repo-check",
|
||||||
|
"-s",
|
||||||
|
"workspace-write",
|
||||||
|
"-C",
|
||||||
|
workdir,
|
||||||
|
];
|
||||||
|
if (model) {
|
||||||
|
codexArgs.push("-m", model);
|
||||||
|
}
|
||||||
|
// 프롬프트는 인자로 넘기지 않고 stdin으로 넣는다. codex.cmd -> powershell.exe ->
|
||||||
|
// codex.ps1 로 이어지는 Windows 셸 릴레이가 공백·한글이 섞인 긴 인자를 깨뜨리는
|
||||||
|
// 것을 실측으로 확인했다(`codex exec` 는 프롬프트 인자가 없으면 stdin으로 읽는다).
|
||||||
|
|
||||||
|
const snapshotBefore = snapshotDir(workdir);
|
||||||
|
|
||||||
|
const result = {
|
||||||
|
scenario,
|
||||||
|
runner: "codex",
|
||||||
|
model: model || "<default>",
|
||||||
|
asked: false,
|
||||||
|
askChannel: null,
|
||||||
|
questionText: null,
|
||||||
|
wroteBeforeAnswer: null,
|
||||||
|
firstVisibleAction: null,
|
||||||
|
finalMessageText: null,
|
||||||
|
toolsUsed: [],
|
||||||
|
durationMs: null,
|
||||||
|
killedReason: null,
|
||||||
|
exitCode: null,
|
||||||
|
error: null,
|
||||||
|
sandboxPolicyRejections: [],
|
||||||
|
windowsSandboxSectionApplied: !!windowsSection,
|
||||||
|
isolationNote:
|
||||||
|
"HOME/USERPROFILE/CODEX_HOME 재정의 + skills.config 이름 차단·재활성화로 격리를 시도했다. " +
|
||||||
|
"이 Windows 빌드에서는 사용자 스코프 스킬 루트(home_dir 기반)가 env override를 우회하는 것을 확인했으므로 " +
|
||||||
|
"무관한 스킬 노출 자체는 완전히 막지 못했을 수 있다. sandboxPolicyRejections 가 비어 있지 않으면 " +
|
||||||
|
"샌드박스 자체가 이 실행에서 막혀 결과가 무효일 가능성이 높다(README 참고).",
|
||||||
|
};
|
||||||
|
|
||||||
|
const logStream = logPath ? fs.createWriteStream(logPath, { flags: "a" }) : null;
|
||||||
|
const startedAt = Date.now();
|
||||||
|
|
||||||
|
const isWin = process.platform === "win32";
|
||||||
|
let codexBin = "codex";
|
||||||
|
let useShell = isWin;
|
||||||
|
if (isWin) {
|
||||||
|
const resolved = resolveCodexBinaryWindows();
|
||||||
|
if (resolved) {
|
||||||
|
codexBin = resolved;
|
||||||
|
useShell = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result.codexBinaryUsed = codexBin;
|
||||||
|
|
||||||
|
const child = spawn(codexBin, codexArgs, {
|
||||||
|
cwd: workdir,
|
||||||
|
stdio: ["pipe", "pipe", "pipe"],
|
||||||
|
shell: useShell,
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
HOME: isolatedHome,
|
||||||
|
USERPROFILE: isolatedHome,
|
||||||
|
CODEX_HOME: isolatedCodexHome,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
child.stdin.write(prompt, "utf8");
|
||||||
|
child.stdin.end();
|
||||||
|
|
||||||
|
let killedByUs = false;
|
||||||
|
function killChild(reason) {
|
||||||
|
if (killedByUs || child.killed) return;
|
||||||
|
killedByUs = true;
|
||||||
|
result.killedReason = reason;
|
||||||
|
try {
|
||||||
|
child.kill("SIGTERM");
|
||||||
|
} catch {
|
||||||
|
// 무시
|
||||||
|
}
|
||||||
|
setTimeout(() => {
|
||||||
|
try {
|
||||||
|
if (!child.killed) child.kill("SIGKILL");
|
||||||
|
} catch {
|
||||||
|
// 무시
|
||||||
|
}
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
const overallTimeout = setTimeout(() => killChild("timeout"), timeoutMs);
|
||||||
|
|
||||||
|
function markAskedIfNeeded(channel, text) {
|
||||||
|
if (result.asked) return;
|
||||||
|
result.asked = true;
|
||||||
|
result.askChannel = channel;
|
||||||
|
result.questionText = text;
|
||||||
|
const after = snapshotDir(workdir);
|
||||||
|
result.wroteBeforeAnswer = diffSnapshots(snapshotBefore, after).count;
|
||||||
|
killChild("asked");
|
||||||
|
}
|
||||||
|
|
||||||
|
child.stderr.on("data", (chunk) => {
|
||||||
|
const text = chunk.toString();
|
||||||
|
if (logStream) logStream.write(`[stderr] ${text}`);
|
||||||
|
if (/rejected: blocked by policy|Rejected\(/.test(text)) {
|
||||||
|
result.sandboxPolicyRejections.push(text.trim().slice(0, 500));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const rl = readline.createInterface({ input: child.stdout, crlfDelay: Infinity });
|
||||||
|
rl.on("line", (line) => {
|
||||||
|
if (logStream) logStream.write(line + "\n");
|
||||||
|
if (!line.trim()) return;
|
||||||
|
let evt;
|
||||||
|
try {
|
||||||
|
evt = JSON.parse(line);
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (evt.type === "item.completed" && evt.item) {
|
||||||
|
const item = evt.item;
|
||||||
|
if (item.type === "agent_message" && item.text) {
|
||||||
|
if (!result.firstVisibleAction) {
|
||||||
|
result.firstVisibleAction = `text:${item.text.slice(0, 120)}`;
|
||||||
|
}
|
||||||
|
const score = scoreQuestionText(item.text);
|
||||||
|
if (score.isQuestion) {
|
||||||
|
markAskedIfNeeded("text", item.text);
|
||||||
|
}
|
||||||
|
result.finalMessageText = item.text;
|
||||||
|
} else if (item.type === "command_execution") {
|
||||||
|
const label = `command_execution:${(item.command || "").slice(0, 80)}`;
|
||||||
|
result.toolsUsed.push(label);
|
||||||
|
if (!result.firstVisibleAction) {
|
||||||
|
result.firstVisibleAction = `tool:${label}`;
|
||||||
|
}
|
||||||
|
} else if (item.type === "error") {
|
||||||
|
result.toolsUsed.push(`error:${(item.message || "").slice(0, 80)}`);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await new Promise((resolve) => {
|
||||||
|
child.on("close", (code) => {
|
||||||
|
result.exitCode = code;
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
child.on("error", (err) => {
|
||||||
|
result.error = String(err && err.message ? err.message : err);
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
clearTimeout(overallTimeout);
|
||||||
|
if (logStream) logStream.end();
|
||||||
|
|
||||||
|
if (result.wroteBeforeAnswer === null) {
|
||||||
|
const after = snapshotDir(workdir);
|
||||||
|
result.wroteBeforeAnswer = diffSnapshots(snapshotBefore, after).count;
|
||||||
|
}
|
||||||
|
result.durationMs = Date.now() - startedAt;
|
||||||
|
|
||||||
|
if (authCopied) {
|
||||||
|
try {
|
||||||
|
fs.unlinkSync(isolatedAuthPath);
|
||||||
|
} catch {
|
||||||
|
// 무시 — 그래도 최선을 다해 지운다
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.mkdirSync(path.dirname(outPath), { recursive: true });
|
||||||
|
fs.writeFileSync(outPath, JSON.stringify(result, null, 2));
|
||||||
|
console.log(JSON.stringify(result));
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error("run-codex.mjs 실패:", err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
295
build/eval/interview/run.mjs
Normal file
295
build/eval/interview/run.mjs
Normal file
|
|
@ -0,0 +1,295 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
// designpaca 인터뷰 게이트 로컬 평가 — 실행 스크립트.
|
||||||
|
// 자세한 사용법·판정 기준은 build/eval/interview/README.md 참고.
|
||||||
|
//
|
||||||
|
// node build/eval/interview/run.mjs --skill-dir <path> --label <name> \
|
||||||
|
// [--runners claude,claude-sdk-auto,claude-sdk-default,codex] [--repeat 3] \
|
||||||
|
// [--scenarios S1,S2] [--claude-model sonnet] [--codex-model gpt-5.1-codex] \
|
||||||
|
// [--permission-mode default] [--timeout-ms 300000]
|
||||||
|
//
|
||||||
|
// --runners 항목은 "이름:반복수"로 개별 반복수를 줄 수 있다(생략하면 --repeat).
|
||||||
|
// 예: --runners claude-sdk-auto:3,claude-sdk-default:1,codex:3
|
||||||
|
// 러너 종류:
|
||||||
|
// claude - run-claude.mjs (`claude -p` CLI, v1)
|
||||||
|
// claude-sdk-auto - run-claude-sdk.mjs, permissionMode=auto (v2, 주 지표)
|
||||||
|
// claude-sdk-default - run-claude-sdk.mjs, permissionMode=default (v2, 보조 지표)
|
||||||
|
// codex - run-codex.mjs
|
||||||
|
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { spawn } from "node:child_process";
|
||||||
|
import { copyDirRecursive } from "./lib/fsutil.mjs";
|
||||||
|
|
||||||
|
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const REPO_ROOT = path.resolve(HERE, "..", "..", "..");
|
||||||
|
|
||||||
|
function parseArgs(argv) {
|
||||||
|
const out = {};
|
||||||
|
for (let i = 0; i < argv.length; i++) {
|
||||||
|
const a = argv[i];
|
||||||
|
if (a.startsWith("--")) {
|
||||||
|
const key = a.slice(2);
|
||||||
|
const next = argv[i + 1];
|
||||||
|
if (next === undefined || next.startsWith("--")) {
|
||||||
|
out[key] = true;
|
||||||
|
} else {
|
||||||
|
out[key] = next;
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function runNode(scriptPath, args, logFile) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const child = spawn(process.execPath, [scriptPath, ...args], {
|
||||||
|
cwd: REPO_ROOT,
|
||||||
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
|
});
|
||||||
|
let out = "";
|
||||||
|
let err = "";
|
||||||
|
child.stdout.on("data", (d) => (out += d));
|
||||||
|
child.stderr.on("data", (d) => (err += d));
|
||||||
|
child.on("close", (code) => {
|
||||||
|
if (logFile) {
|
||||||
|
fs.writeFileSync(logFile, `--- stdout ---\n${out}\n--- stderr ---\n${err}\n--- exit ---\n${code}\n`);
|
||||||
|
}
|
||||||
|
resolve({ code, out, err });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 통과 여부를 판정한다. 기계적으로 판정 가능한 것만 pass/fail 로 확정하고,
|
||||||
|
* 원문을 직접 읽어야 하는 경우(S7의 "범위 질문 1개 이하" 같은 것)는 REVIEW 로 남긴다.
|
||||||
|
*/
|
||||||
|
function computeVerdict(scenario, record) {
|
||||||
|
if (record.error) return { verdict: "ERROR", note: record.error };
|
||||||
|
if (scenario.expect === "ask") {
|
||||||
|
if (record.asked && record.wroteBeforeAnswer === 0) return { verdict: "PASS", note: "" };
|
||||||
|
if (!record.asked) return { verdict: "FAIL", note: "질문 없이 끝남(또는 타임아웃)" };
|
||||||
|
return { verdict: "FAIL", note: `질문 전에 파일 ${record.wroteBeforeAnswer}건 생성/수정` };
|
||||||
|
}
|
||||||
|
// expect === "no-interview"
|
||||||
|
if (!record.asked) return { verdict: "PASS", note: "" };
|
||||||
|
if (scenario.allowScopeQuestion) {
|
||||||
|
return { verdict: "REVIEW", note: "질문이 나왔다 — 범위 확인 질문 1개 이하인지 원문을 직접 읽고 판정할 것" };
|
||||||
|
}
|
||||||
|
return { verdict: "FAIL", note: "위임/국소 경로인데 인터뷰가 나옴" };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const args = parseArgs(process.argv.slice(2));
|
||||||
|
const skillDirArg = args["skill-dir"];
|
||||||
|
const label = args.label;
|
||||||
|
if (!skillDirArg || !label) {
|
||||||
|
console.error("사용법: run.mjs --skill-dir <path> --label <name> [--runners claude,codex] [--repeat 3] [--scenarios S1,S2] ...");
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
const skillDir = path.resolve(skillDirArg);
|
||||||
|
if (!fs.existsSync(path.join(skillDir, "SKILL.md"))) {
|
||||||
|
console.error(`오류: ${skillDir} 에 SKILL.md 가 없다.`);
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultRepeat = Number(args.repeat || 3);
|
||||||
|
const runnerSpecs = String(args.runners || "claude,codex")
|
||||||
|
.split(",")
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((spec) => {
|
||||||
|
const [name, repeatStr] = spec.split(":");
|
||||||
|
return { name, repeat: repeatStr ? Number(repeatStr) : defaultRepeat };
|
||||||
|
});
|
||||||
|
const scenarioFilter = args.scenarios ? String(args.scenarios).split(",").map((s) => s.trim()) : null;
|
||||||
|
const claudeModel = args["claude-model"] || "sonnet";
|
||||||
|
const codexModel = args["codex-model"] || null;
|
||||||
|
const permissionMode = args["permission-mode"] || null;
|
||||||
|
const timeoutMs = args["timeout-ms"] || "300000";
|
||||||
|
|
||||||
|
const scenarios = JSON.parse(fs.readFileSync(path.join(HERE, "scenarios.json"), "utf8")).filter(
|
||||||
|
(s) => !scenarioFilter || scenarioFilter.includes(s.id)
|
||||||
|
);
|
||||||
|
|
||||||
|
const runsRoot = path.join(REPO_ROOT, "outputs", "eval", "interview", "runs", label);
|
||||||
|
fs.mkdirSync(runsRoot, { recursive: true });
|
||||||
|
const resultsPath = path.join(runsRoot, "results.jsonl");
|
||||||
|
const resultsStream = fs.createWriteStream(resultsPath, { flags: "a" });
|
||||||
|
|
||||||
|
|
||||||
|
for (const { name: runner, repeat } of runnerSpecs) {
|
||||||
|
for (const scenario of scenarios) {
|
||||||
|
for (let n = 1; n <= repeat; n++) {
|
||||||
|
const runId = `${scenario.id}-${n}`;
|
||||||
|
const runDir = path.join(runsRoot, runner, runId);
|
||||||
|
const workdir = path.join(runDir, "work");
|
||||||
|
fs.mkdirSync(workdir, { recursive: true });
|
||||||
|
|
||||||
|
if (scenario.fixture) {
|
||||||
|
copyDirRecursive(path.join(HERE, "fixtures", scenario.fixture), workdir);
|
||||||
|
}
|
||||||
|
|
||||||
|
let skillPathForCodex = null;
|
||||||
|
if (runner === "claude" || runner === "claude-sdk-auto" || runner === "claude-sdk-default") {
|
||||||
|
const dest = path.join(workdir, ".claude", "skills", "designpaca");
|
||||||
|
fs.mkdirSync(dest, { recursive: true });
|
||||||
|
copyDirRecursive(skillDir, dest);
|
||||||
|
} else if (runner === "codex") {
|
||||||
|
const dest = path.join(workdir, ".agents", "skills", "designpaca");
|
||||||
|
fs.mkdirSync(dest, { recursive: true });
|
||||||
|
copyDirRecursive(skillDir, dest);
|
||||||
|
skillPathForCodex = path.join(dest, "SKILL.md");
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawLogExt = runner === "codex" ? "raw.jsonl" : "raw.ndjson";
|
||||||
|
const resultPath = path.join(runDir, "result.json");
|
||||||
|
const rawLogPath = path.join(runDir, rawLogExt);
|
||||||
|
const procLogPath = path.join(runDir, "process.log");
|
||||||
|
|
||||||
|
process.stdout.write(`[${label}] ${runner} ${runId} 실행 중...\n`);
|
||||||
|
|
||||||
|
if (runner === "claude") {
|
||||||
|
const cliArgs = [
|
||||||
|
"--workdir", workdir,
|
||||||
|
"--scenario", scenario.id,
|
||||||
|
"--prompt", scenario.prompt,
|
||||||
|
"--model", claudeModel,
|
||||||
|
"--out", resultPath,
|
||||||
|
"--log", rawLogPath,
|
||||||
|
"--timeout-ms", String(timeoutMs),
|
||||||
|
];
|
||||||
|
if (permissionMode) cliArgs.push("--permission-mode", permissionMode);
|
||||||
|
await runNode(path.join(HERE, "run-claude.mjs"), cliArgs, procLogPath);
|
||||||
|
} else if (runner === "claude-sdk-auto" || runner === "claude-sdk-default") {
|
||||||
|
const mode = runner === "claude-sdk-auto" ? "auto" : "default";
|
||||||
|
const cliArgs = [
|
||||||
|
"--workdir", workdir,
|
||||||
|
"--scenario", scenario.id,
|
||||||
|
"--prompt", scenario.prompt,
|
||||||
|
"--model", claudeModel,
|
||||||
|
"--permission-mode", mode,
|
||||||
|
"--out", resultPath,
|
||||||
|
"--log", rawLogPath,
|
||||||
|
"--timeout-ms", String(timeoutMs),
|
||||||
|
];
|
||||||
|
await runNode(path.join(HERE, "run-claude-sdk.mjs"), cliArgs, procLogPath);
|
||||||
|
} else if (runner === "codex") {
|
||||||
|
const cliArgs = [
|
||||||
|
"--workdir", workdir,
|
||||||
|
"--scenario", scenario.id,
|
||||||
|
"--prompt", scenario.prompt,
|
||||||
|
"--skill-path", skillPathForCodex,
|
||||||
|
"--out", resultPath,
|
||||||
|
"--log", rawLogPath,
|
||||||
|
"--timeout-ms", String(timeoutMs),
|
||||||
|
];
|
||||||
|
if (codexModel) cliArgs.push("--model", codexModel);
|
||||||
|
await runNode(path.join(HERE, "run-codex.mjs"), cliArgs, procLogPath);
|
||||||
|
} else {
|
||||||
|
console.error(`알 수 없는 러너: ${runner}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let record;
|
||||||
|
try {
|
||||||
|
record = JSON.parse(fs.readFileSync(resultPath, "utf8"));
|
||||||
|
} catch (e) {
|
||||||
|
record = { scenario: scenario.id, runner, run: n, error: `결과 파일을 읽지 못함: ${e.message}` };
|
||||||
|
}
|
||||||
|
record.run = n;
|
||||||
|
record.label = label;
|
||||||
|
const { verdict, note } = computeVerdict(scenario, record);
|
||||||
|
record.verdict = verdict;
|
||||||
|
record.verdictNote = note;
|
||||||
|
|
||||||
|
resultsStream.write(JSON.stringify(record) + "\n");
|
||||||
|
process.stdout.write(` -> asked=${record.asked} wroteBeforeAnswer=${record.wroteBeforeAnswer} verdict=${verdict}\n`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resultsStream.end();
|
||||||
|
|
||||||
|
// summary.md — 같은 label로 여러 번(러너를 나눠서) 실행해도 누적되도록
|
||||||
|
// 이번 호출의 기록만이 아니라 results.jsonl 전체를 다시 읽어서 만든다.
|
||||||
|
const cumulativeRecords = fs
|
||||||
|
.readFileSync(resultsPath, "utf8")
|
||||||
|
.split("\n")
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((line) => JSON.parse(line));
|
||||||
|
|
||||||
|
const bySc = {};
|
||||||
|
for (const r of cumulativeRecords) {
|
||||||
|
const key = `${r.scenario}\t${r.runner}`;
|
||||||
|
if (!bySc[key]) bySc[key] = [];
|
||||||
|
bySc[key].push(r);
|
||||||
|
}
|
||||||
|
let md = `# 인터뷰 게이트 평가 요약 — ${label}\n\n`;
|
||||||
|
md += `생성 시각: ${new Date().toISOString()}\n\n`;
|
||||||
|
md += `스킬 경로: \`${path.relative(REPO_ROOT, skillDir)}\`\n\n`;
|
||||||
|
const allScenarios = JSON.parse(fs.readFileSync(path.join(HERE, "scenarios.json"), "utf8"));
|
||||||
|
const scenarioById = Object.fromEntries(allScenarios.map((s) => [s.id, s]));
|
||||||
|
const pairs = [...new Set(Object.keys(bySc))]
|
||||||
|
.map((k) => k.split("\t"))
|
||||||
|
.sort((a, b) => (a[0] + a[1]).localeCompare(b[0] + b[1]));
|
||||||
|
|
||||||
|
md += `| 시나리오 | expect | 러너 | PASS | FAIL | REVIEW | ERROR | 통과율 |\n`;
|
||||||
|
md += `|---|---|---|---|---|---|---|---|\n`;
|
||||||
|
for (const [scId, runner] of pairs) {
|
||||||
|
const list = bySc[`${scId}\t${runner}`] || [];
|
||||||
|
const pass = list.filter((r) => r.verdict === "PASS").length;
|
||||||
|
const fail = list.filter((r) => r.verdict === "FAIL").length;
|
||||||
|
const review = list.filter((r) => r.verdict === "REVIEW").length;
|
||||||
|
const error = list.filter((r) => r.verdict === "ERROR").length;
|
||||||
|
const rate = list.length ? `${Math.round((pass / list.length) * 100)}%` : "-";
|
||||||
|
const expect = scenarioById[scId] ? scenarioById[scId].expect : "?";
|
||||||
|
md += `| ${scId} | ${expect} | ${runner} | ${pass} | ${fail} | ${review} | ${error} | ${rate} |\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
md += `\n## 러너별 메타(askChannel 분포, AskUserQuestion 유무, 모델)\n\n`;
|
||||||
|
const runnerNames = [...new Set(cumulativeRecords.map((r) => r.runner))].sort();
|
||||||
|
md += `| 러너 | 실행 수 | askChannel=tool | askChannel=text | asked=false | AskUserQuestion 노출 | 모델 |\n`;
|
||||||
|
md += `|---|---|---|---|---|---|---|\n`;
|
||||||
|
for (const runner of runnerNames) {
|
||||||
|
const list = cumulativeRecords.filter((r) => r.runner === runner);
|
||||||
|
const tool = list.filter((r) => r.askChannel === "tool").length;
|
||||||
|
const text = list.filter((r) => r.askChannel === "text").length;
|
||||||
|
const notAsked = list.filter((r) => !r.asked).length;
|
||||||
|
const auq = list.some((r) => r.askUserQuestionAvailable === true)
|
||||||
|
? "있음"
|
||||||
|
: list.some((r) => r.askUserQuestionAvailable === false)
|
||||||
|
? "없음"
|
||||||
|
: "N/A(v1은 미측정)";
|
||||||
|
const models = [...new Set(list.map((r) => r.model).filter(Boolean))];
|
||||||
|
md += `| ${runner} | ${list.length} | ${tool} | ${text} | ${notAsked} | ${auq} | ${models.join(", ") || "-"} |\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
md += `\n## 실행별 상세\n\n`;
|
||||||
|
for (const r of cumulativeRecords) {
|
||||||
|
md += `### ${r.scenario} / ${r.runner} / run ${r.run} — ${r.verdict}\n\n`;
|
||||||
|
if (r.verdictNote) md += `판정 메모: ${r.verdictNote}\n\n`;
|
||||||
|
md += `- asked: ${r.asked}, askChannel: ${r.askChannel}, wroteBeforeAnswer: ${r.wroteBeforeAnswer}\n`;
|
||||||
|
if (r.model || r.askUserQuestionAvailable !== undefined) {
|
||||||
|
md += `- model: ${r.model || "-"}, askUserQuestionAvailable: ${r.askUserQuestionAvailable}\n`;
|
||||||
|
}
|
||||||
|
md += `- firstVisibleAction: ${String(r.firstVisibleAction || "").slice(0, 200)}\n`;
|
||||||
|
if (r.sandboxPolicyRejections && r.sandboxPolicyRejections.length) {
|
||||||
|
md += `- sandboxPolicyRejections: ${r.sandboxPolicyRejections.length}건 (이 실행의 exec가 막혔을 수 있음, README 참고)\n`;
|
||||||
|
}
|
||||||
|
if (r.questionText) {
|
||||||
|
md += `- 질문 원문:\n\n\`\`\`\n${r.questionText.slice(0, 1500)}\n\`\`\`\n\n`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const summaryPath = path.join(runsRoot, "summary.md");
|
||||||
|
fs.writeFileSync(summaryPath, md);
|
||||||
|
process.stdout.write(`\n요약 저장: ${summaryPath}\n`);
|
||||||
|
process.stdout.write(`결과 JSONL: ${resultsPath}\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error("run.mjs 실패:", err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
80
build/eval/interview/scenarios.json
Normal file
80
build/eval/interview/scenarios.json
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "S1",
|
||||||
|
"expect": "ask",
|
||||||
|
"fixture": "s1-empty",
|
||||||
|
"skillHome": "any",
|
||||||
|
"prompt": "꽃집 홍보 사이트 하나 만들어줘."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "S2",
|
||||||
|
"expect": "ask",
|
||||||
|
"fixture": null,
|
||||||
|
"skillHome": "any",
|
||||||
|
"prompt": "개인 포트폴리오 랜딩 페이지 만들어줘. 나는 UX 디자이너야."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "S3",
|
||||||
|
"expect": "ask",
|
||||||
|
"fixture": null,
|
||||||
|
"skillHome": "any",
|
||||||
|
"prompt": "우리 스타트업 SaaS 랜딩 페이지 만들어줘. 이름은 '스택노트'이고 개발자용 노트 앱이야."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "S4",
|
||||||
|
"expect": "ask",
|
||||||
|
"fixture": "s4-design-md",
|
||||||
|
"skillHome": "any",
|
||||||
|
"prompt": "요금제 페이지 추가해줘."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "S5",
|
||||||
|
"expect": "no-interview",
|
||||||
|
"fixture": null,
|
||||||
|
"skillHome": "any",
|
||||||
|
"prompt": "꽃집 사이트 만들어줘. 질문하지 말고 알아서 해줘."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "S6",
|
||||||
|
"expect": "no-interview",
|
||||||
|
"fixture": "s6-design-md",
|
||||||
|
"skillHome": "any",
|
||||||
|
"prompt": "히어로 CTA 버튼 hover 색이 너무 약해. 조금 진하게 해줘."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "S7",
|
||||||
|
"expect": "no-interview",
|
||||||
|
"fixture": "s7-review",
|
||||||
|
"skillHome": "any",
|
||||||
|
"allowScopeQuestion": true,
|
||||||
|
"prompt": "이 페이지 디자인 리뷰해줘."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "S8",
|
||||||
|
"expect": "ask",
|
||||||
|
"fixture": "s8-repo-hint",
|
||||||
|
"skillHome": "any",
|
||||||
|
"prompt": "우리 사이트 새로 만들어줘."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "S9",
|
||||||
|
"expect": "ask",
|
||||||
|
"fixture": null,
|
||||||
|
"skillHome": "any",
|
||||||
|
"prompt": "카페 '온기' 홈페이지 만들어줘. 따뜻하고 아늑한 느낌으로."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "S10",
|
||||||
|
"expect": "ask",
|
||||||
|
"fixture": null,
|
||||||
|
"skillHome": "any",
|
||||||
|
"prompt": "빨리 랜딩 하나 뽑아줘. 요가 스튜디오야."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "S11",
|
||||||
|
"expect": "no-interview",
|
||||||
|
"fixture": null,
|
||||||
|
"skillHome": "any",
|
||||||
|
"prompt": "동네 필라테스 스튜디오 '결' 랜딩 페이지 만들어줘. 목표는 체험 수업 예약 하나로 유도하는 거야. 톤은 차분하고 미니멀하게. 기존 로고나 브랜드 색은 없어. 연락처랑 가격은 아직 정해진 게 없으니까 그냥 placeholder로 넣어두고 진행해줘."
|
||||||
|
}
|
||||||
|
]
|
||||||
Loading…
Add table
Add a link
Reference in a new issue