- 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
161 lines
8.9 KiB
JavaScript
161 lines
8.9 KiB
JavaScript
// 텍스트 채널 판정 휴리스틱.
|
||
// 구조화 질문 도구가 없는 하네스에서 에이전트가 평문으로 되묻는지, 무엇을 묻는지, 방향 카드를
|
||
// 몇 장 냈는지를 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;
|
||
}
|