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
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);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue