designpaca/build/eval/interview/lib/route.mjs
Yun Chan 9bcb45cbd3 feat(core,cli): harden install engine and extend skill lint and interview eval
- update only touches the recorded project root; project installs do not
  follow symlinks or junctions; repeated --force rotates .orig backups;
  uninstall never deletes outside the recorded root
- run npm/npx without a shell on Windows and pin optional tool versions
- skip the update check in CI and non-interactive output
- lint: cross-document section pointers, preflight row ids, gate rule
  numbers, per-document size budgets, gate copy equality
- eval: scenarios S19-S21, route and read-cost rejudging, interpretation
  card judging, temp-dir previews and table-form cards no longer fail
2026-10-06 16:55:38 +09:00

114 lines
7.1 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 경로 판정(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(`(?<![가-힣A-Za-z/·,])${DECO}(${KO})${DECO}\\s*(?:제작|구현|수정|작업)?\\s*경로(?!\\s*(?:가|는|도)?\\s*아니)`, "g") },
{ strength: 2, re: new RegExp(`경로(?:\\s*판정)?\\s*(?:는|은|:|:)[^\\n]{0,80}?${DECO}(${KO})${DECO}(?![가-힣]*\\s*(?:이|가)?\\s*아니)`, "g") },
{ strength: 2, re: new RegExp(`^[ \\t>*-]*${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-Za-z/·,])(${KO})\\s*수정\\s*(?:으로|입니다|이라|이므로|이어서)`, "g") },
{ strength: 1, re: new RegExp(`(?<![가-힣A-Za-z/·,])${DECO}(${KO})${DECO}\\s*(?:으로|로)\\s*(?:진행|판단|판정|처리|올)`, "g") },
];
function lineAt(text, index) {
const start = text.lastIndexOf("\n", index - 1) + 1;
const end = text.indexOf("\n", index);
return text.slice(start, end === -1 ? text.length : end).trim();
}
/**
* 텍스트에서 경로 표기를 모두 뽑는다. 반환: [{ route, strength, index, line }] (나온 순서).
* "국소/연장 구현 경로"처럼 경로 이름을 나란히 든 언급은 표기로 보지 않는다.
*/
export function detectRoutes(text) {
const s = String(text || "");
const found = [];
for (const p of PATTERNS) {
for (const m of s.matchAll(p.re)) {
const route = canon(m[1]);
if (!route) continue;
const at = m.index + m[0].indexOf(m[1]);
// 바로 뒤에 다른 경로 이름이 `/`·`·` 로 이어지면 나열이다.
if (new RegExp(`^\\s*[/·,]\\s*(?:${KO})`).test(s.slice(at + m[1].length))) continue;
found.push({ route, strength: p.strength, index: at, line: lineAt(s, at) });
}
}
found.sort((a, b) => 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}` };
}