feat: v2.2.194 — Post-gen Term Validator (결정론적 글로서리 검증)
v2.2.192 Terminology Dictionary 가 *instructional* 지시 (LLM 에게 표준 표기 사용 권유) 였다면, 이번엔 *deterministic* 검증 — LLM 이 지시를 안 따랐을 때 결정론적 정규식 스캔. 신규 모듈: src/agent/termValidator.ts - parseGlossary() — .astra/glossary.md 정규식 파싱 (mtime 캐시) Pattern 1: **Canonical** (X: typo1, typo2, ...) — typo 등장 시 "→ Canonical 권장" Pattern 2: H2/H3 "금지/비추/forbidden/avoid/don't" 섹션의 - ❌ "phrase" - validateTermUsage() — 정규식 스캔 + 발견 횟수 - formatTermValidatorFooter() — markdown 한 줄 footer False-positive 필터: - 한글 1음절·영문 1자·공백 포함 토큰 제외 - 영문 단어 경계 매치, 한글 substring Wiring: - agent.ts _maybeRunTermValidator — Self-Check 직후, swallow 패턴 - /glossary reload — Term Validator 캐시도 함께 비움 신규 설정: g1nation.termValidatorEnabled (기본 true) Footer 누적: - v2.2.191 🔍 Self-check (LLM 호출, opt-in) - v2.2.194 🔤 Term validator (정규식, on by default) 시너지: Terminology Dictionary(instructional, 작성 중) + Term Validator(deterministic, 작성 후) → 사용자가 .astra/glossary.md 한 곳만 관리하면 2단 자동 동작. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -165,6 +165,7 @@ import { buildAstraModeSystemPrompt } from './agent/handlePrompt/buildAstraModeS
|
||||
import { computeBudgetedRequest } from './agent/handlePrompt/computeBudgetedRequest';
|
||||
import { processFinalAnswer } from './agent/handlePrompt/processFinalAnswer';
|
||||
import { postHocSelfCheck, formatSelfCheckFooter, DEFAULT_SELF_CHECK_OPTIONS } from './agent/postHocSelfCheck';
|
||||
import { validateTermUsage, formatTermValidatorFooter } from './agent/termValidator';
|
||||
import { applyAutoContinuation } from './agent/handlePrompt/applyAutoContinuation';
|
||||
|
||||
export interface ChatMessage {
|
||||
@@ -1236,6 +1237,9 @@ export class AgentExecutor {
|
||||
userPrompt: prompt || '',
|
||||
assistantAnswer: finalAssistantContent,
|
||||
});
|
||||
// ── Term Validator (v2.2.194) — 결정론적 정규식 스캔. LLM 호출 없음, 즉시 실행. ──
|
||||
// 글로서리 forbidden 단어가 답변에 등장 시 footer flag. 위반 없으면 ✓.
|
||||
this._maybeRunTermValidator(finalAssistantContent);
|
||||
} else {
|
||||
this.webview.postMessage({ type: 'streamChunk', value: finalAssistantContent });
|
||||
}
|
||||
@@ -1442,6 +1446,22 @@ export class AgentExecutor {
|
||||
} catch { /* swallow — self-check never breaks the turn */ }
|
||||
}
|
||||
|
||||
/**
|
||||
* Post-gen Term Validator — 글로서리 forbidden 단어가 답변에 등장하는지 결정론적 스캔.
|
||||
* LLM 호출 없음, 동기 실행 (수 ms). 글로서리 없거나 disabled 면 silent no-op.
|
||||
*/
|
||||
private _maybeRunTermValidator(answer: string): void {
|
||||
try {
|
||||
const cfg = getConfig();
|
||||
if (cfg.termValidatorEnabled === false) return;
|
||||
if (!answer || !answer.trim()) return;
|
||||
const result = validateTermUsage(answer, cfg.glossaryPath || '.astra/glossary.md');
|
||||
if (!result.ran || result.dictionarySize === 0) return; // 글로서리 없거나 비어 있음 → 표시 안 함
|
||||
const footer = formatTermValidatorFooter(result);
|
||||
if (footer) this.webview?.postMessage({ type: 'streamChunk', value: footer });
|
||||
} catch { /* swallow — validator never breaks the turn */ }
|
||||
}
|
||||
|
||||
private async callNonStreaming(params: {
|
||||
baseUrl: string;
|
||||
modelName: string;
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
/**
|
||||
* 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';
|
||||
|
||||
const DEFAULT_GLOSSARY_REL_PATH = '.astra/glossary.md';
|
||||
|
||||
interface ForbiddenEntry {
|
||||
forbidden: string;
|
||||
suggested?: string; // canonical 권장 표기 (X-marker 케이스만)
|
||||
source: 'x-marker' | 'banned-section';
|
||||
}
|
||||
|
||||
interface ValidatorCache {
|
||||
mtime: number;
|
||||
entries: ForbiddenEntry[];
|
||||
}
|
||||
|
||||
const _cache = new Map<string, ValidatorCache>();
|
||||
|
||||
export function clearTermValidatorCache(): void {
|
||||
_cache.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 파싱. mtime 캐시.
|
||||
*/
|
||||
function parseGlossary(filePath: string): ForbiddenEntry[] {
|
||||
try {
|
||||
if (!fs.existsSync(filePath)) return [];
|
||||
const st = fs.statSync(filePath);
|
||||
const cached = _cache.get(filePath);
|
||||
if (cached && cached.mtime === st.mtimeMs) return cached.entries;
|
||||
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
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 }[] = [];
|
||||
let sm: RegExpExecArray | null;
|
||||
let lastIdx = 0;
|
||||
const matches: { idx: number; level: number; title: string }[] = [];
|
||||
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<string>();
|
||||
const deduped = entries.filter((e) => {
|
||||
const k = `${e.source}::${e.forbidden.toLowerCase()}`;
|
||||
if (seen.has(k)) return false;
|
||||
seen.add(k);
|
||||
return true;
|
||||
});
|
||||
|
||||
_cache.set(filePath, { mtime: st.mtimeMs, entries: deduped });
|
||||
lastIdx = 0; // suppress lint unused
|
||||
void lastIdx;
|
||||
return deduped;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
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 = parseGlossary(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}_`;
|
||||
}
|
||||
@@ -167,6 +167,12 @@ export interface IAgentConfig {
|
||||
glossaryPath: string;
|
||||
/** Glossary 본문 시스템 프롬프트 cap (chars). 너무 크면 토큰 비용↑. 기본 4000. */
|
||||
glossaryMaxBodyLength: number;
|
||||
/**
|
||||
* Post-gen Term Validator — 답변 완료 후 글로서리 forbidden 단어 결정론적 스캔.
|
||||
* Terminology Dictionary (v2.2.192) 의 *instructional* 지시를 *deterministic* 검증으로 보완.
|
||||
* LLM 호출 없음 (정규식), 매 turn 안전 실행. footer 한 줄 표시. 기본 true.
|
||||
*/
|
||||
termValidatorEnabled: boolean;
|
||||
/**
|
||||
* Global Knowledge Mix weight (0–100). Controls how much the assistant leans on
|
||||
* Second Brain evidence vs. model general knowledge when answering.
|
||||
@@ -439,6 +445,7 @@ export function getConfig(): IAgentConfig {
|
||||
glossaryEnabled: cfg.get<boolean>('glossaryEnabled', true),
|
||||
glossaryPath: cfg.get<string>('glossaryPath', '.astra/glossary.md') || '.astra/glossary.md',
|
||||
glossaryMaxBodyLength: Math.max(500, Math.min(20000, cfg.get<number>('glossaryMaxBodyLength', 4000))),
|
||||
termValidatorEnabled: cfg.get<boolean>('termValidatorEnabled', true),
|
||||
knowledgeMixSecondBrainWeight: Math.max(0, Math.min(100, Math.round(
|
||||
cfg.get<number>('knowledgeMix.secondBrainWeight', 50)
|
||||
))),
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
GLOSSARY_TEMPLATE,
|
||||
clearGlossaryCache,
|
||||
} from '../../retrieval/terminologyBlock';
|
||||
import { clearTermValidatorCache } from '../../agent/termValidator';
|
||||
|
||||
/**
|
||||
* Datacollect "라디오" slash 명령 라우터.
|
||||
@@ -3982,7 +3983,8 @@ async function runGlossary(arg: string, view: any, _context?: vscode.ExtensionCo
|
||||
|
||||
if (trimmed === 'reload') {
|
||||
clearGlossaryCache();
|
||||
chunk(view, '\n🔄 글로서리 캐시 비움. 다음 채팅 turn 에 파일 재읽기.\n');
|
||||
clearTermValidatorCache();
|
||||
chunk(view, '\n🔄 글로서리 캐시 비움 (system prompt + Term Validator 모두). 다음 채팅 turn 에 파일 재읽기.\n');
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user