// 경로 판정(V-36). SKILL.md 0-2 는 "경로와 근거를 한 줄로 말한다"를 출력 계약으로 둔다 // (예: "국소 — 새 화면 없음, 토큰 재정의 없음, 섹션 1개."). 응답 텍스트에서 그 한 줄을 뽑아 시나리오의 // 기대 경로(`expectedRoute`)와 대조한다. 모델을 부르지 않는 정적 재판정이다. // // 실측한 표기(outputs/eval/interview/runs/redteam-2026-10-02·-r2, 2026-10-02): // "경로는 **연장**(새 화면, design.md 있음 …)" Claude S4 // "경로 판정: 새 화면(A) + design.md 없음 → **전체 경로**" Claude S11 // "리뷰 경로(국소 수정 없음, 평가만) — 0단계만 거치고 …" Claude S17 ← "국소 수정"은 경로 표기가 아니다 // "국소 경로(섹션 1개, 새 토큰 체계 아님)로 판단해 …" Claude S6 // "국소 수정으로 진행합니다." / "국소 수정입니다." Codex S6·S13 // "새 사이트를 만드는 전체 경로로 진행합니다." Codex S5 // "기존 `design.md`가 있어 … ‘연장’ 경로입니다." Codex S4 // "… 그건 리뷰가 아니라 국소/연장 구현 경로로 넘어가는 일" Claude S7 ← 둘을 나란히 든 언급은 표기가 아니다 // // 못 잡는 것: 경로 이름 없이 풀어 쓴 말("버튼 하나만 고칩니다"), 질문 도구의 선택지 안에만 적은 경로, // 표·코드 블록 안의 경로 표(스킬 본문을 인용한 경우는 걸러 내지 못한다 — 판정 메모의 근거 줄을 읽고 확인한다). export const ROUTES = ["전체", "전체(리디자인)", "연장", "국소", "리뷰", "피드백"]; const KO = "전체\\s*\\(\\s*리디자인\\s*\\)|리디자인|전체|연장|국소|리뷰|피드백"; const EN = "full\\s*\\(\\s*redesign\\s*\\)|redesign|full|extension|extend|local|review|feedback"; const EN_MAP = { full: "전체", redesign: "전체(리디자인)", extension: "연장", extend: "연장", local: "국소", review: "리뷰", feedback: "피드백" }; // 경로 이름을 감싸는 꾸밈(굵게·따옴표·백틱) const DECO = "[*_`'\"‘’“”「」]*"; function canon(raw) { const s = String(raw).replace(/\s+/g, "").toLowerCase(); if (/리디자인|redesign/.test(s)) return "전체(리디자인)"; if (EN_MAP[s]) return EN_MAP[s]; return ROUTES.includes(s) ? s : null; } // 강한 표기: "X 경로", "경로는 X", "경로: X", "X — 근거"(줄 머리), 영어 "X route|path", "Route: X". // 약한 표기: "X 수정으로 진행", "X(으)로 진행/판단/처리" — 강한 표기가 하나도 없을 때만 쓴다. const PATTERNS = [ { strength: 2, re: new RegExp(`(?*-]*${DECO}(${KO})${DECO}\\s*[—–:]\\s`, "gm") }, { strength: 2, re: new RegExp(`\\b(${EN})\\s+(?:route|path)\\b`, "gi") }, { strength: 2, re: new RegExp(`\\b(?:route|path)\\s*(?:is|:)\\s*${DECO}(${EN})\\b`, "gi") }, { strength: 1, re: new RegExp(`(? a.index - b.index || b.strength - a.strength); // 같은 자리를 여러 패턴이 잡은 것은 하나로 합친다. return found.filter((f, i) => i === 0 || f.index !== found[i - 1].index); } /** * 실행이 말한 최종 경로. 강한 표기가 있으면 그중 마지막(경로를 올렸으면 올린 뒤가 최종이다), 없으면 약한 표기의 마지막. * 반환: { route, line, routes: 서로 다른 경로를 나온 순서로 } | null */ export function detectRoute(text) { const all = detectRoutes(text); if (all.length === 0) return null; const strong = all.filter((f) => f.strength === 2); const pool = strong.length ? strong : all; const last = pool[pool.length - 1]; const routes = []; for (const f of pool) if (routes[routes.length - 1] !== f.route) routes.push(f.route); return { route: last.route, line: last.line, routes }; } /** * 시나리오의 기대 경로와 대조한다. `expectedRoute` 는 문자열이거나 허용 경로 배열이다. 없으면 null(판정 대상 아님). * PASS 말한 최종 경로가 기대와 같다 * FAIL 다른 경로를 말했다 * REVIEW 경로를 말하지 않았다(출력 계약 미이행 — 원문을 읽고 확정), 또는 전체 ↔ 전체(리디자인) 차이 * 기본 판정(judge)과 따로 낸다. 스킬이 호출되지 않은 실행은 호출 쪽에서 거른다. * @param {{expectedRoute?: string|string[]}} scenario * @param {Array} events normalizeClaude/normalizeCodex 결과(모든 턴의 텍스트를 본다) */ export function judgeRoute(scenario, events) { if (!scenario?.expectedRoute) return null; const expected = [].concat(scenario.expectedRoute); const text = events .filter((e) => e.kind === "text") .map((e) => e.text) .join("\n\n"); const got = detectRoute(text); const base = { expected: expected.join("|"), route: got?.route ?? null, routes: got?.routes ?? [], evidence: got?.line?.slice(0, 200) ?? null }; if (!got) return { ...base, verdict: "REVIEW", note: "경로와 근거 한 줄이 없다(SKILL.md 0-2 출력 계약) — 원문을 읽고 확정할 것" }; const moved = got.routes.length > 1 ? ` (말한 순서: ${got.routes.join(" → ")})` : ""; if (expected.includes(got.route)) return { ...base, verdict: "PASS", note: `경로 ${got.route}${moved}` }; const family = (r) => r.replace(/\(리디자인\)$/, ""); if (expected.some((e) => family(e) === family(got.route))) { return { ...base, verdict: "REVIEW", note: `경로 ${got.route} — 기대는 ${base.expected}(전체와 전체(리디자인)의 차이)${moved}` }; } return { ...base, verdict: "FAIL", note: `경로 ${got.route} — 기대는 ${base.expected}${moved}` }; }