designpaca/build/eval/interview/lib/text-heuristic.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

161 lines
8.9 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.

// 텍스트 채널 판정 휴리스틱.
// 구조화 질문 도구가 없는 하네스에서 에이전트가 평문으로 되묻는지, 무엇을 묻는지, 방향 카드를
// 몇 장 냈는지를 1차로 가린다. 원문은 항상 결과에 같이 남고, 애매한 판정은 REVIEW 로 남긴다.
//
// 01 레드팀 보고서 F13: 이전 판정은 물음표 1개나 번호 목록 1줄만 있어도 "질문"으로 봤다. 그래서
// 진행 계획("1. 레퍼런스 조사 2. 방향 결정")이나 수사적 물음표("왜 이 선택일까?")가 질문으로 집계됐다.
// 지금은 (1) 메시지 끝부분에 물음표나 요청형 어미가 있어야 질문이고, (2) 번호 목록은 단독 신호가 아니며,
// (3) 브리프 슬롯을 겨냥했는지(detectSlots)를 따로 본다.
const REQUEST_PHRASES = [
/알려\s*주(?:세요|시면|실\s*수)/,
/말씀해\s*주(?:세요|시면)/,
/선택해\s*주(?:세요|시면)/,
/골라\s*주(?:세요|시면)/,
/답(?:변)?해\s*주(?:세요|시면)/,
/정해\s*주(?:세요|시면)/,
/확인해\s*주(?:세요|시면)/,
/알려\s*줄래/,
/알려\s*줘/,
/골라\s*줘/,
/선택해\s*줘/,
/여쭤/,
/궁금합니다/,
/답(?:을|변을)\s*(?:받은|주시면|기다)/,
/\b(?:let me know|please (?:choose|pick|confirm|tell|share|answer))\b/i,
/\bwhich (?:one|direction|option)\b/i,
];
// 질문을 판정할 때 보는 "끝부분". 수사적 물음표가 본문 중간에 있어도 끝이 질문이 아니면 질문이 아니다.
const TAIL_LINES = 8;
function tailOf(text) {
const lines = String(text || "")
.split(/\r?\n/)
.map((l) => l.trim())
.filter(Boolean);
return lines.slice(-TAIL_LINES).join("\n");
}
/**
* @param {string} text 에이전트가 낸 텍스트 블록 원문
* @returns {{isQuestion: boolean, trailingQuestion: boolean, questionMarks: number, tailQuestionMarks: number, optionLines: number, phraseHits: string[]}}
*/
export function scoreQuestionText(text) {
const s = String(text || "");
const tail = tailOf(s);
const questionMarks = (s.match(/[??]/g) || []).length;
const tailQuestionMarks = (tail.match(/[??]/g) || []).length;
const optionLines = (s.match(/(^|\n)\s*(?:[0-9]{1,2}[.)]|[A-Ea-e][.)])\s+\S/g) || []).length;
const phraseHits = REQUEST_PHRASES.filter((re) => re.test(tail)).map((re) => re.source);
const trailingQuestion = tailQuestionMarks >= 1 || phraseHits.length >= 1;
// 번호 목록(optionLines)은 기록만 하고 판정에 쓰지 않는다 — 진행 계획도 번호 목록이다.
return { isQuestion: trailingQuestion, trailingQuestion, questionMarks, tailQuestionMarks, optionLines, phraseHits };
}
// 브리프 슬롯(SKILL.md 0단계, brief-interview.md §3)별 키워드. "색" 같은 일반어는 hover 수정에도 나오므로
// 브랜드 맥락이 붙은 표현만 쓴다.
export const SLOT_PATTERNS = {
industry: [/업종/, /업태/, /성격/, /어떤\s*(?:가게|비즈니스|사업|서비스|매장|브랜드)/, /주력\s*(?:상품|메뉴|서비스)/, /\b(?:industry|business type|what kind of business)\b/i],
goal: [/목표\s*행동/, /목표/, /전환/, /방문자가[^\n]{0,20}(?:하길|했으면|하도록)/, /(?:예약|구매|주문|문의|가입|구독|상담)(?:으로|를|을)?\s*(?:유도|받|이어)/, /\b(?:goal|primary action|conversion|call to action)\b/i],
audience: [/타깃/, /타겟/, /대상\s*(?:고객|사용자|층)/, /고객층/, /누구(?:에게|를\s*위한)/, /\b(?:audience|target users?|who is (?:it|this) for)\b/i],
tone: [/톤/, /분위기/, /무드/, /느낌/, /스타일\s*방향/, /\b(?:tone|mood|vibe|look and feel)\b/i],
brandAssets: [/브랜드\s*(?:자산|색|컬러|가이드)/, /로고/, /서체/, /폰트/, /사진/, /이미지\s*(?:자산|소스)/, /\b(?:logo|brand (?:colou?rs?|assets?|guidelines?)|fonts?|photos?)\b/i],
properNouns: [/상호/, /가게\s*이름/, /브랜드\s*(?:이름|명)/, /연락처/, /전화/, /주소/, /가격/, /요금/, /영업\s*시간/, /실제\s*(?:값|수치|정보)/, /\b(?:business name|contact|address|pric(?:e|ing)|opening hours)\b/i],
language: [/기본\s*언어/, /어떤\s*언어/, /한국어로/, /영어로/, /\b(?:which language|primary language)\b/i],
};
// 리뷰 경로에서 허용되는 범위 질문 키워드(브리프 슬롯이 아니라 리뷰 입력에 대한 질문).
export const SCOPE_PATTERNS = [/범위/, /어느\s*(?:페이지|화면|섹션|부분)/, /스크린샷/, /\bURL\b/i, /diff/i, /기준/, /관점/, /\b(?:scope|which page)\b/i];
/** 텍스트가 겨냥한 브리프 슬롯 목록. */
export function detectSlots(text) {
const s = String(text || "");
return Object.entries(SLOT_PATTERNS)
.filter(([, res]) => res.some((re) => re.test(s)))
.map(([slot]) => slot);
}
export function isScopeQuestion(text) {
return SCOPE_PATTERNS.some((re) => re.test(String(text || "")));
}
/**
* 방향 카드 머리를 찾는다. 반환은 서로 다른 식별자별 {id, name}(name 은 머리에서 읽은 카드 이름, 없으면 "").
* 숫자·원문자와 알파벳은 같은 식별자로 본다(1=①=A).
*
* 강한 형식(방향 A, A안, 1안, 굵게/제목 머리의 "A." "A ·")은 그대로 센다. 약한 형식(굵게/제목 머리의
* "1." "①")은 진행 계획·인터뷰 질문 번호와 모양이 같으므로 (1) 텍스트에 방향·시안 맥락이 있고 (2) 머리 줄이
* 물음으로 끝나지 않고 (3) 머리가 브리프 슬롯을 겨냥하지 않을 때만 센다. 실측 redteam-2026-10-02: Codex S12-2
* "**A · 꽃으로 쓰는 편지**", Claude S16-1 "**1. 보태니컬 저널**"를 0장으로 셌다.
*/
export function parseDirectionCards(text) {
const s = String(text || "");
const cards = new Map();
const CIRCLED = "①②③④⑤⑥";
const norm = (id) => {
const c = id.toUpperCase();
const ci = CIRCLED.indexOf(c);
if (ci !== -1) return String.fromCharCode(65 + ci);
return /[1-9]/.test(c) ? String.fromCharCode(64 + Number(c)) : c;
};
const cleanName = (name) =>
String(name)
.replace(/\*\*/g, "")
.replace(/\s+[—–-]\s.*$/, "")
.replace(/\s*\((?:추천|recommended)\)\s*$/i, "")
.replace(/^['‘"“「]|['’"”」]$/g, "")
.trim();
const add = (id, name = "") => {
const k = norm(id);
const cleaned = cleanName(name);
const prev = cards.get(k);
if (!prev || (!prev.name && cleaned)) cards.set(k, { id: k, name: cleaned });
};
const strong = [
/(?:방향|시안|컨셉|콘셉트|Direction|Option|Concept)\s*([A-Fa-f1-6])(?![A-Za-z0-9]|\s?안)/g, // "방향 3안"은 장 수다
/(?<![A-Za-z0-9])([A-F])\s?안(?=[\s:.)\]*·,—-]|[을은이과와의]|$)/gm,
// "1안" 머리·선택. "방향 3안"·"총 2안"처럼 장 수를 말하는 경우는 식별자가 아니다.
/(?<!(?:방향|시안|총|모두|후보)\s?)(?<![0-9A-Za-z])([1-6])안(?=[\s:.)\]*·,—"“'‘-]|[을은이과와의]|$)/gm,
];
for (const re of strong) for (const m of s.matchAll(re)) add(m[1]);
// 굵게/제목 머리: "**A. 이름**", "### A) 이름", "**A · 이름**"
for (const m of s.matchAll(/^[ \t]*(?:#{2,6}[ \t]*|\*\*[ \t]*)([A-F])(?:[.):]|[ \t]*[·•])[ \t]+(\S[^\n]*)$/gm)) add(m[1], m[2]);
// 표 머리 형식: "| | A · 이름 | B · 이름 |" (실측: Codex 가 카드를 비교표 한 장으로 낸다).
for (const line of s.split(/\n/)) {
if (!/^\s*\|/.test(line)) continue;
const cells = line.split("|").map((c) => c.replace(/\*\*/g, "").trim());
const hits = cells.map((c) => c.match(/^([A-F])[ \t]*[·•.)][ \t]*(\S.*)$/)).filter(Boolean);
if (hits.length >= 2) for (const m of hits) add(m[1], m[2]);
}
// 약한 형식: "**1. 이름**", "### ① 이름". 머리 줄을 뺀 본문에 방향·시안 맥락과 선택 요청이 함께 있어야 한다
// (진행 계획 "**1. 레퍼런스 조사** **2. 방향 결정**"을 카드로 세지 않는다).
const WEAK_HEAD = /^[ \t]*(?:#{2,6}[ \t]*|\*\*[ \t]*)(?:([1-6])[.)][ \t]+|([①-⑥])[ \t]*)(\S[^\n]*)$/gm;
const body = s.replace(WEAK_HEAD, "");
const directionContext = /방향|시안|컨셉|콘셉트|\bdirections?\b|\bconcepts?\b/i.test(body);
const choiceRequest = /골라|고르|선택|어느\s*(?:방향|쪽|안|것)|\bwhich (?:one|direction|option)\b|\b(?:pick|choose)\b/i.test(body);
if (directionContext && choiceRequest) {
for (const m of s.matchAll(WEAK_HEAD)) {
const head = m[3];
if (/[??][ \t]*(?:\*\*)?[ \t]*$/.test(head)) continue;
if (detectSlots(cleanName(head)).length) continue;
add(m[1] || m[2], head);
}
}
return [...cards.values()];
}
/** 방향 카드 수(서로 다른 식별자 수). */
export function countDirectionCards(text) {
return parseDirectionCards(text).length;
}
/** 대략적인 언어 판정: 한글 음절 비율. */
export function hangulRatio(text) {
const s = String(text || "").replace(/\s+/g, "");
if (!s) return 0;
const letters = s.match(/[A-Za-z가-힣]/g) || [];
if (!letters.length) return 0;
const h = letters.filter((c) => /[가-힣]/.test(c)).length;
return h / letters.length;
}