/** * Post-generation Term Validator — 답변 *완료 후* 정규식/사전 기반 *결정론적* 스캔. * * v2.2.192 의 Terminology Dictionary 가 *instructional* (LLM 에게 표준 표기 사용 지시) 이면, * 이건 *deterministic* — LLM 이 지시를 안 따랐을 때 catch. * * Glossary 파싱 — 두 패턴 인식: * 1. **표준 표기**: `- **Canonical** (X: typo1, typo2, ...)` * → typo1/typo2 가 답변에 등장하면 "→ Canonical 권장" flag * 2. **금지 표현**: H2/H3 제목에 "금지"/"비추" 포함된 섹션의 `- ❌ "phrase"` 또는 `- ❌ phrase` * → phrase 가 답변에 등장하면 "삭제/재작성 권장" flag * * Forbidden 후보 필터: * - 1~30 chars * - 공백/괄호/이모지 등 description 토큰 제외 * - 빈 문자열, 한 자 (한글 1음절 제외) 등 false-positive 위험 토큰 제외 * * 비용: LLM 호출 없음, 정규식 1회. 매 turn 안전하게 실행 가능. */ import * as fs from 'fs'; import * as path from 'path'; import * as vscode from 'vscode'; import { createMtimeFileCache } from '../lib/mtimeFileCache'; const DEFAULT_GLOSSARY_REL_PATH = '.astra/glossary.md'; interface ForbiddenEntry { forbidden: string; suggested?: string; // canonical 권장 표기 (X-marker 케이스만) source: 'x-marker' | 'banned-section'; } /** Parsed forbidden entries 캐시 — mtime 기반, 파일 편집 시 자동 재read+parse. */ const _parsedCache = createMtimeFileCache('term-validator', (raw) => parseGlossaryRaw(raw)); export function clearTermValidatorCache(): void { _parsedCache.clear(); } function getGlossaryFilePath(relPath: string): string | null { const folders = vscode.workspace.workspaceFolders; if (!folders || folders.length === 0) return null; return path.join(folders[0].uri.fsPath, relPath); } /** 토큰이 forbidden 후보로 적합한가 — false-positive 줄임. */ function isValidForbiddenToken(token: string): boolean { if (!token) return false; const t = token.trim(); if (t.length === 0 || t.length > 30) return false; // 빈 괄호·이모지·문장 등 제외 — 영문/한글/숫자/하이픈/언더스코어만 허용 (+ 슬래시 — 명령어 지원) if (!/^[\w가-힣\-./]+$/u.test(t)) return false; // 한글 1음절은 false-positive 위험 (조사 등) — 제외 if (/^[가-힣]$/.test(t)) return false; // 영문 1글자는 거의 항상 false-positive — 제외 if (/^[a-zA-Z]$/.test(t)) return false; return true; } /** * Glossary markdown raw → forbidden entries. mtime 캐시는 mtimeFileCache 가 담당. */ function parseGlossaryRaw(content: string): ForbiddenEntry[] { const entries: ForbiddenEntry[] = []; // ─── Pattern 1: **Canonical** (X: typo1, typo2, ...) ─── // `**ASTRA** (X: astra, Astra 외)` — Astra 외 같은 description 은 후보 필터로 제거. const xPattern = /\*\*([^*]+)\*\*\s*\(X:\s*([^)]+)\)/g; let m: RegExpExecArray | null; while ((m = xPattern.exec(content)) !== null) { const canonical = m[1].trim(); const variants = m[2].split(',').map((v) => v.trim()); for (const v of variants) { if (isValidForbiddenToken(v)) { entries.push({ forbidden: v, suggested: canonical, source: 'x-marker' }); } } } // ─── Pattern 2: 금지 섹션 내 `- ❌ "..."` 또는 `- ❌ ...` ─── // H2/H3 제목에 "금지" 또는 "비추" 포함된 섹션 본문만 스캔. const sectionRegex = /^(#{2,3})\s+(.+)$/gm; const sections: { headerEnd: number; nextHeaderStart: number; title: string }[] = []; const matches: { idx: number; level: number; title: string }[] = []; let sm: RegExpExecArray | null; while ((sm = sectionRegex.exec(content)) !== null) { matches.push({ idx: sm.index + sm[0].length, level: sm[1].length, title: sm[2].trim() }); } for (let i = 0; i < matches.length; i++) { const cur = matches[i]; const next = matches[i + 1]; sections.push({ headerEnd: cur.idx, nextHeaderStart: next ? next.idx : content.length, title: cur.title, }); } for (const sec of sections) { if (!/금지|비추|forbidden|avoid|don'?t/i.test(sec.title)) continue; const body = content.slice(sec.headerEnd, sec.nextHeaderStart); const itemRe = /^-\s*❌\s*(?:"([^"]+)"|'([^']+)'|([^\n—]+?))(?:\s*[—–-].*)?$/gm; let im: RegExpExecArray | null; while ((im = itemRe.exec(body)) !== null) { const phrase = (im[1] || im[2] || im[3] || '').trim(); if (isValidForbiddenToken(phrase)) { entries.push({ forbidden: phrase, source: 'banned-section' }); } } } // Dedup const seen = new Set(); return entries.filter((e) => { const k = `${e.source}::${e.forbidden.toLowerCase()}`; if (seen.has(k)) return false; seen.add(k); return true; }); } export interface TermViolation { forbidden: string; suggested?: string; source: 'x-marker' | 'banned-section'; /** 답변 내 발견된 횟수. */ occurrences: number; } export interface TermValidationResult { /** validator 실행 여부 — 글로서리 없거나 disabled 면 false. */ ran: boolean; /** 사전 entry 수 — 0 이면 글로서리 비어 있음. */ dictionarySize: number; violations: TermViolation[]; /** 0 이면 위반 없음. */ totalViolations: number; } function escapeRegex(s: string): string { return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } /** * 답변 텍스트에서 forbidden 단어 발견 — 대소문자 무시, 단어 경계 매치 (영문), * 한글은 substring (한글은 word boundary 가 의미 없음). */ function countMatches(text: string, forbidden: string): number { const isAscii = /^[\w\-./]+$/.test(forbidden); const re = isAscii ? new RegExp(`\\b${escapeRegex(forbidden)}\\b`, 'gi') : new RegExp(escapeRegex(forbidden), 'g'); const matches = text.match(re); return matches ? matches.length : 0; } export function validateTermUsage( answer: string, glossaryRelPath: string = DEFAULT_GLOSSARY_REL_PATH, ): TermValidationResult { const fp = getGlossaryFilePath(glossaryRelPath); if (!fp || !fs.existsSync(fp)) { return { ran: false, dictionarySize: 0, violations: [], totalViolations: 0 }; } const entries = _parsedCache.read(fp) ?? []; if (entries.length === 0) { return { ran: true, dictionarySize: 0, violations: [], totalViolations: 0 }; } if (!answer || !answer.trim()) { return { ran: true, dictionarySize: entries.length, violations: [], totalViolations: 0 }; } const violations: TermViolation[] = []; let total = 0; for (const e of entries) { const n = countMatches(answer, e.forbidden); if (n > 0) { violations.push({ ...e, occurrences: n }); total += n; } } violations.sort((a, b) => b.occurrences - a.occurrences); return { ran: true, dictionarySize: entries.length, violations, totalViolations: total }; } /** * Footer 마크다운 한 줄. 위반 없으면 깔끔한 ✓, 있으면 상세. * Self-Check footer 와 시각적으로 통일. */ export function formatTermValidatorFooter(result: TermValidationResult): string { if (!result.ran) return ''; if (result.dictionarySize === 0) return ''; if (result.totalViolations === 0) { return `\n_🔤 **Term validator**: ✓ 사전 ${result.dictionarySize}개 항목, 위반 없음_`; } const top = result.violations.slice(0, 4).map((v) => { if (v.suggested) return `"${v.forbidden}" → "${v.suggested}"`; return `"${v.forbidden}" (금지)`; }).join(' · '); const more = result.violations.length > 4 ? ` _+${result.violations.length - 4}건_` : ''; return `\n_🔤 **Term validator**: ⚠️ 위반 ${result.totalViolations}건 — ${top}${more}_`; }