7bec20620a
아키텍처 감사 결과 HIGH 2건 + MED 2건 + LOW 1건 — 7 라운드 정리 시리즈.
기능 변경 없음, 순수 구조 정리.
**slashRouter.ts: 4,174 → 201줄 (–3,973, –95%)**
**agent.ts: 1,617 → 1,551줄 (–66, –4%)**
v2.2.195: eventSourcedStore + SystemPromptBlock registry
- createEventStore<E>(opts) — 4 store (customers/hire/runway/feedback) I/O 240줄 중복 제거
- _turnCtx 5 named string field → 1 Map<string, string> (새 verification block 추가 25곳→1곳)
- buildAstraModeSystemPrompt: 5 ternary gate + 5 위치 → 1 for-loop join
v2.2.196: trackers cluster split
- src/features/teamops/handlers/_shared.ts (fmtKrw/parseAmount/daysUntil/parseTaskOwner/stageEmoji/STAGE_ORDER/TERMINAL_STAGES)
- src/features/teamops/handlers/trackers.ts (runway/customers/hire)
- src/features/teamops/handlers/index.ts (barrel)
- extension.ts 에 side-effect import (순환 import 회피)
v2.2.197: mtimeFileCache + PostAnswerHook registry
- src/lib/mtimeFileCache.ts — createMtimeFileCache<T>(name, parse) (terminologyBlock + termValidator 2-cache invariant 자동화)
- src/agent/postAnswerHooks/{types,index}.ts — Devil/SelfCheck/TermValidator 3 _maybeX method → 1 runPostAnswerHooks(ctx) loop
- agent.ts –66줄
v2.2.198: dashboards cluster split
- src/features/teamops/handlers/dashboards.ts (morning/evening/cohort/weekly)
v2.2.199: coordination + communication clusters split
- src/features/teamops/handlers/coordination.ts (task/decisions/onesie/blocked/standup)
- src/features/teamops/handlers/communication.ts (draft/feedback)
- callLmSynthesis export 노출 (communication 이 사용)
- 옛 parseTaskOwner local 정의 삭제 (_shared.ts 사용)
v2.2.200: system cluster split
- src/features/system/handlers.ts (memory/glossary/help)
v2.2.201: datacollect cluster split + LLM 인프라 추출
- src/features/datacollect/handlers.ts (research/benchmark/youtube/blog/wikify/meet)
- src/features/datacollect/llm.ts (callLmSynthesis + repairKoreanGlitches + bridgeErrorRemedy)
- slashRouter import 4개로 축소: vscode/logInfo/getBridgeBaseUrl/bridgeErrorRemedy
**최종 slashRouter (201줄):**
- REGISTRY Map + registerSlashCommand/listSlashCommands/isSlashCommand
- handleSlashCommand (dispatcher + 에러 처리)
- Webview interface + chunk helper
- getRecentSlashCommands ring buffer (actionability scoring 용)
**미래 부담 감소 metrics:**
- 새 슬래시 명령: god-file 끝에 함수 + register → 1 파일 + 1 register call
- 새 verification block: 5곳 편집 → 1 set call
- 새 event store: 60줄 boilerplate → createEventStore 한 줄
- 새 post-answer hook: 3 step → 1 push
- 새 mtime cache: Map + invariant 관리 → createMtimeFileCache 한 줄
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
203 lines
8.0 KiB
TypeScript
203 lines
8.0 KiB
TypeScript
/**
|
|
* 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<ForbiddenEntry[]>('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<string>();
|
|
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}_`;
|
|
}
|