From 72faa0748088fbe104429f28d824ec69a5f05710 Mon Sep 17 00:00:00 2001 From: g1nation Date: Thu, 11 Jun 2026 19:28:46 +0900 Subject: [PATCH] =?UTF-8?q?feat(growth):=20Correction=20Loop=20=E2=80=94?= =?UTF-8?q?=20=EC=A0=95=EC=A0=95=201=ED=9A=8C=EA=B0=80=20=EC=8B=9C?= =?UTF-8?q?=EC=8A=A4=ED=85=9C=20=EC=84=B8=20=EA=B3=B3=EC=9D=84=20=EC=84=B1?= =?UTF-8?q?=EC=9E=A5=EC=8B=9C=ED=82=A4=EB=8A=94=20=ED=8C=8C=EC=9D=B4?= =?UTF-8?q?=ED=94=84=EB=9D=BC=EC=9D=B8=20(v2.2.223)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit self-evolving 고도화: 사용자 정정이 곧 Ground Truth — 정답지를 사람이 따로 만들지 않고, 태그 통계가 리포트에 머물지 않고 다음 턴의 행동을 바꾼다. ① 정정 감지·태깅 (correctionLoop.ts + agent.ts 훅, fire-and-forget): - "아니야/틀렸어/~가 아니라" 류 정정 발화 감지 (보수적 — 추임새 "아니"는 제외) - LLM 오류 분류 (사실오류/근거누락/맥락누락/추론오류/지시불이행/형식오류, 실패 시 휴리스틱 fallback) → error-tag frontmatter 레슨(lessons/) 저장 - 동시에 회귀 케이스 적립: .astra/eval/corrections.jsonl {질문, 틀린답, 정정} ② 주간 성장 사이클 확장 (1.5단계): - 정정 회귀 테스트: 정정받은 질문을 두뇌 검색 컨텍스트와 함께 재실행 → LLM-judge "같은 실수 반복?" 판정 → growth/regression-report.md (사이클당 ≤8건) - 약점 프로필: 최근 60일 태그 통계 → growth/weakness-profile.json ③ 결핍의 행동화 (memoryContext): - GROUNDING 약함 + agent scope 적용 중 → 전체 두뇌 1회 재검색 (scope 가 정답 문서를 가리는 경우 구제, 더 강한 근거일 때만 채택) - 그래도 약함 → 학습 큐에 지식 공백 자동 proposed 등록 (질문 해시 중복 차단, 20건 폭주 방지, 승인은 사람 — Permission Based Learning 유지) - 약점 프로필 → [자기검토] 블록 주입 (태그 2회 이상만): "너는 최근 X 정정을 N회 받았다 — <유형별 자기검토 지시>" 테스트 25건 추가 (감지 패턴·프로필 집계·큐 등록·영속화·fallback 분류). Co-Authored-By: Claude Opus 4.8 --- package-lock.json | 4 +- package.json | 2 +- src/agent.ts | 23 ++ src/features/growth/growthCycleWatcher.ts | 47 +++ src/intelligence/correctionLoop.ts | 379 ++++++++++++++++++++++ src/lib/contextBuilders/memoryContext.ts | 47 ++- tests/correctionLoop.test.ts | 158 +++++++++ 7 files changed, 650 insertions(+), 10 deletions(-) create mode 100644 src/intelligence/correctionLoop.ts create mode 100644 tests/correctionLoop.test.ts diff --git a/package-lock.json b/package-lock.json index f0aae8a..4d9b73a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "astra", - "version": "2.2.222", + "version": "2.2.223", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "astra", - "version": "2.2.222", + "version": "2.2.223", "license": "MIT", "dependencies": { "@lmstudio/sdk": "^1.5.0", diff --git a/package.json b/package.json index c043e5b..e84ef6b 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "astra", "displayName": "Astra", "description": "The personal intelligence layer for Antigravity and VS Code. A private cognitive partner for deep project context, memory, and proactive strategic decision-making.", - "version": "2.2.222", + "version": "2.2.223", "publisher": "g1nation", "license": "MIT", "icon": "assets/icon.png", diff --git a/src/agent.ts b/src/agent.ts index 53e8b7f..155fc2c 100644 --- a/src/agent.ts +++ b/src/agent.ts @@ -20,6 +20,7 @@ import { SessionManager } from './core/session'; import { AgentWorkflowManager } from './agents/AgentWorkflowManager'; import { buildAstraModeArchitectureContext } from './lib/contextBuilders/astraModeArchitecture'; import { isScheduleRequest, buildScheduleContext } from './lib/contextBuilders/scheduleContext'; +import { looksLikeCorrection, captureCorrection } from './intelligence/correctionLoop'; import { shouldUseMultiAgentWorkflow } from './lib/contextBuilders/multiAgentRouting'; import { buildThinkingPartnerResponseContract } from './lib/contextBuilders/thinkingPartnerContract'; import { buildDroppedHistorySummary } from './lib/contextBuilders/droppedHistorySummary'; @@ -536,6 +537,28 @@ export class AgentExecutor { } } + // [Correction Loop ①] 이 발화가 직전 답변에 대한 *정정*이면 fire-and-forget + // 캡처 — 오류 분류 → 태깅 레슨 + 회귀 케이스(.astra/eval/corrections.jsonl). + // 정정 자체가 Ground Truth 가 되어 주간 회귀 테스트·약점 프로필의 원료가 된다. + // 턴 응답을 막지 않는다 (await 없음). + if (prompt && loopDepth === 0 && activeBrain?.localBrainPath && looksLikeCorrection(prompt)) { + const visible = this.chatHistory.filter(m => !m.internal); + const lastAssistant = [...visible].reverse().find(m => m.role === 'assistant'); + const lastUserIdx = lastAssistant ? visible.lastIndexOf(lastAssistant) - 1 : -1; + const priorQuestion = lastUserIdx >= 0 && visible[lastUserIdx]?.role === 'user' ? visible[lastUserIdx].content : ''; + if (lastAssistant && priorQuestion) { + void captureCorrection({ + brainPath: activeBrain.localBrainPath, + question: priorQuestion, + wrongAnswer: lastAssistant.content, + correction: prompt, + llm: { baseUrl: config.ollamaUrl, model: configDefaultModel }, + }).then(file => { + if (file) logInfo('Correction Loop: 정정 캡처 완료.', { lesson: file }); + }).catch((e: any) => logError('Correction Loop 캡처 실패 (무시).', { error: e?.message ?? String(e) })); + } + } + // 2. Setup History if (prompt !== null) { if (loopDepth === 0) { diff --git a/src/features/growth/growthCycleWatcher.ts b/src/features/growth/growthCycleWatcher.ts index f1cbc63..f9e3b20 100644 --- a/src/features/growth/growthCycleWatcher.ts +++ b/src/features/growth/growthCycleWatcher.ts @@ -33,6 +33,10 @@ import { runResearch, formatProposalMarkdown } from '../../intelligence/research import type { ExistingKnowledgeRef } from '../../intelligence/knowledgeValidation'; import { loadQueue, saveQueue, mergeNeedsIntoQueue, formatQueueMarkdown, LEARNING_QUEUE_REL_PATH } from '../../intelligence/learningQueue'; import { simpleChatCompletion } from '../../intelligence/llmCall'; +import { + loadCorrectionCases, computeWeaknessProfile, saveWeaknessProfile, + runRegressionCase, formatRegressionReport, +} from '../../intelligence/correctionLoop'; import { TelegramHttpClient } from '../../integrations/telegram/telegramClient'; import { TELEGRAM_TOKEN_SECRET_KEY } from '../../extension/telegramCommands'; @@ -111,6 +115,49 @@ export async function runGrowthCycleOnce(context: vscode.ExtensionContext): Prom } } catch (e: any) { logError('성장 사이클: 검색 평가 실패.', { error: e?.message ?? String(e) }); } + // (1.5) Correction Loop — 정정 회귀 테스트 + 약점 프로필 갱신. + // a. 약점 프로필: 최근 60일 정정 태그 통계 → weakness-profile.json + // (memoryContext 가 다음 턴부터 자기검토 블록으로 주입 — 통계가 행동을 바꾼다) + // b. 회귀: 최근 정정받은 질문을 두뇌 검색 컨텍스트와 함께 다시 풀어 + // "같은 실수 반복?" 을 LLM-judge 로 판정 — 정정이 곧 Ground Truth. + try { + const cases = loadCorrectionCases(brain.localBrainPath); + if (cases.length > 0) { + saveWeaknessProfile(brain.localBrainPath, computeWeaknessProfile(cases, now.getTime())); + if (config.defaultModel && config.ollamaUrl) { + const MAX_REGRESSION_PER_CYCLE = 8; + const recent = cases.slice(-MAX_REGRESSION_PER_CYCLE); + const orchestrator = new RetrievalOrchestrator(); + const allFiles = findBrainFiles(brain.localBrainPath); + getBrainTokenIndex(brain.localBrainPath, allFiles); + const llm = { baseUrl: config.ollamaUrl, model: config.defaultModel }; + const answerFn = async (question: string): Promise => { + // 실제 채팅과 동일하게 두뇌 근거를 주고 답하게 한다 (검색 없는 맨몸 답변은 + // 회귀 판정이 아니라 모델 암기 테스트가 되어버림). + const refs = orchestrator.rankBrainForEval(question, brain, { + limit: 5, chunkLevelRetrieval: config.chunkLevelRetrieval === true, chunkTargetChars: config.chunkTargetChars, + }).slice(0, 5).map(r => { + try { return `[${r.relativePath}]\n${fs.readFileSync(path.join(brain.localBrainPath, r.relativePath), 'utf8').slice(0, 1500)}`; } + catch { return ''; } + }).filter(Boolean).join('\n\n'); + return simpleChatCompletion( + '두뇌 발췌를 근거로 간결히 답하라. 근거 없는 내용은 추정임을 명시하라.', + `[두뇌 발췌]\n${refs || '(없음)'}\n\n[질문] ${question}`, + { ...llm, temperature: 0.2, maxTokens: 700, timeoutMs: 120000 }, + ); + }; + const results = []; + for (const c of recent) results.push(await runRegressionCase(c, answerFn, llm)); + fs.writeFileSync( + path.join(growthDir, 'regression-report.md'), + formatRegressionReport(results, { dateStr: now.toLocaleString() }), 'utf8', + ); + const repeated = results.filter(r => r.repeated === true).length; + summary.push(`정정 회귀 ${results.length}건 중 재발 ${repeated}건${repeated > 0 ? ' ⚠️' : ''}`); + } + } + } catch (e: any) { logError('성장 사이클: 정정 회귀/약점 프로필 실패.', { error: e?.message ?? String(e) }); } + // (2) 학습 큐 갱신 (Need Engine) let proposedCount = 0; try { diff --git a/src/intelligence/correctionLoop.ts b/src/intelligence/correctionLoop.ts new file mode 100644 index 0000000..15b86dc --- /dev/null +++ b/src/intelligence/correctionLoop.ts @@ -0,0 +1,379 @@ +/** + * Correction Loop — 사용자 정정 1회가 시스템을 세 군데서 성장시키는 단일 파이프라인. + * + * 사용자 정정 ("아니야, 그거 6월이야") + * ① 감지(looksLikeCorrection) + LLM 오류 분류(classifyCorrection) + * ├→ 태깅된 레슨 저장 (lessons/ — error-tag frontmatter) + * └→ 회귀 케이스 적립 (.astra/eval/corrections.jsonl — {질문, 틀린답, 정정}) + * ② 주간 성장 사이클이 회귀 재검사("같은 실수 반복?") + 태그 통계 → 약점 프로필 + * ③ 약점 프로필(.astra/growth/weakness-profile.json)이 시스템 프롬프트에 자동 주입 + * + * 설계 원칙: + * - 정답지를 사람이 만들지 않는다 — 정정 자체가 Ground Truth. + * - 통찰→행동 경로가 기계적이다 — 태그 통계가 리포트에 머물지 않고 다음 턴 프롬프트를 바꾼다. + * - 투명성 — 프로필·케이스 모두 사람이 열어 수정/삭제 가능한 파일 (Permission Based Learning). + * - 캡처는 fire-and-forget — 정정 턴의 응답 속도에 영향 없음. + */ +import * as fs from 'fs'; +import * as path from 'path'; +import { simpleChatCompletion } from './llmCall'; +import { loadQueue, saveQueue } from './learningQueue'; + +// ── 타입/상수 ──────────────────────────────────────────────────────────────── + +export const ERROR_TAGS = ['사실오류', '근거누락', '맥락누락', '추론오류', '지시불이행', '형식오류'] as const; +export type ErrorTag = typeof ERROR_TAGS[number] | '기타'; + +export interface CorrectionCase { + ts: string; // ISO + errorTag: ErrorTag; + /** 틀린 답을 만들었던 사용자 질문. */ + question: string; + /** 틀린 답 발췌 (저장 비용 제한). */ + wrongAnswer: string; + /** 사용자의 정정 발화 — Ground Truth. */ + correction: string; + /** 분류 LLM 이 뽑은 한 줄 요지 (레슨 제목 겸용). */ + title: string; +} + +export const CORRECTIONS_REL_PATH = path.join('.astra', 'eval', 'corrections.jsonl'); +export const WEAKNESS_PROFILE_REL_PATH = path.join('.astra', 'growth', 'weakness-profile.json'); + +const MAX_FIELD_CHARS = 600; + +// ── ① 정정 감지 (보수적 — 오탐은 레슨 노이즈가 되므로 명확한 신호만) ───────── + +const CORRECTION_HEAD_RE = /^\s*(아니야|아니지|아닌데|틀렸|그게\s*아니|잘못\s*(알|됐|했)|땡|노노)/; +const CORRECTION_ANY_RE = /(틀렸(어|네|잖|다)|사실(이|과)\s*(아니|다르|달라|다름)|정정(해|할게|하자)|잘못\s*된\s*정보|가\s*아니라\s*|이\s*아니라\s*|착각(했|하)|헛소리|지어내)/; + +/** + * 사용자 발화가 직전 답변에 대한 *정정*인지. 짧은 명령("아니 그거 말고 이거 해줘")과 + * 구분하기 위해 직전 assistant 답변 존재는 호출부가 보장한다. + */ +export function looksLikeCorrection(prompt: string): boolean { + const t = (prompt || '').trim(); + if (t.length < 4 || t.length > 1500) return false; + return CORRECTION_HEAD_RE.test(t) || CORRECTION_ANY_RE.test(t); +} + +// ── ① 오류 분류 (LLM, 실패 시 휴리스틱 fallback) ──────────────────────────── + +const CLASSIFY_SYSTEM = [ + '너는 AI 답변 오류 분류기다. 사용자가 AI 답변을 정정했다. 오류 유형을 하나만 고르고 한 줄 요지를 써라.', + '유형: 사실오류(틀린 사실/수치/날짜), 근거누락(출처 없이 단정), 맥락누락(대화/문서 맥락 놓침), 추론오류(논리 비약), 지시불이행(요구사항 무시), 형식오류(형식/언어 문제)', + '반드시 JSON 한 줄만 출력: {"tag":"<유형>","title":"<요지 40자 이내>"}', +].join('\n'); + +export async function classifyCorrection( + question: string, + wrongAnswer: string, + correction: string, + llm: { baseUrl: string; model: string }, +): Promise<{ tag: ErrorTag; title: string }> { + const fallback = (): { tag: ErrorTag; title: string } => ({ + tag: /(출처|근거|소스)/.test(correction) ? '근거누락' + : /(아까|위에|전에|문서|말했)/.test(correction) ? '맥락누락' + : '사실오류', + title: correction.slice(0, 40).replace(/\n/g, ' '), + }); + try { + const user = [ + `[질문] ${question.slice(0, MAX_FIELD_CHARS)}`, + `[AI 답변 발췌] ${wrongAnswer.slice(0, MAX_FIELD_CHARS)}`, + `[사용자 정정] ${correction.slice(0, MAX_FIELD_CHARS)}`, + ].join('\n'); + const raw = await simpleChatCompletion(CLASSIFY_SYSTEM, user, { + baseUrl: llm.baseUrl, model: llm.model, temperature: 0.1, maxTokens: 120, timeoutMs: 30000, + }); + const m = raw.match(/\{[\s\S]*?\}/); + if (!m) return fallback(); + const parsed = JSON.parse(m[0]); + const tag = (ERROR_TAGS as readonly string[]).includes(parsed?.tag) ? parsed.tag as ErrorTag : '기타'; + const title = String(parsed?.title || '').trim().slice(0, 60) || fallback().title; + return { tag, title }; + } catch { + return fallback(); + } +} + +// ── ① 영속화: 회귀 케이스 + 태깅 레슨 ──────────────────────────────────────── + +export function appendCorrectionCase(brainPath: string, c: CorrectionCase): boolean { + try { + const file = path.join(brainPath, CORRECTIONS_REL_PATH); + fs.mkdirSync(path.dirname(file), { recursive: true }); + const row = { + ...c, + question: c.question.slice(0, MAX_FIELD_CHARS), + wrongAnswer: c.wrongAnswer.slice(0, MAX_FIELD_CHARS), + correction: c.correction.slice(0, MAX_FIELD_CHARS), + }; + fs.appendFileSync(file, JSON.stringify(row) + '\n', 'utf8'); + return true; + } catch { + return false; + } +} + +export function loadCorrectionCases(brainPath: string, limit = 200): CorrectionCase[] { + try { + const file = path.join(brainPath, CORRECTIONS_REL_PATH); + if (!fs.existsSync(file)) return []; + const out: CorrectionCase[] = []; + for (const line of fs.readFileSync(file, 'utf8').split('\n')) { + const t = line.trim(); + if (!t || t.startsWith('//')) continue; + try { + const o = JSON.parse(t); + if (o && typeof o.question === 'string' && typeof o.correction === 'string') out.push(o); + } catch { /* skip bad line */ } + } + return out.slice(-limit); + } catch { + return []; + } +} + +/** 정정 레슨 카드 — 기존 lessons/ 템플릿과 같은 구조 + error-tag frontmatter. */ +export function correctionLessonMarkdown(c: CorrectionCase, today: string): string { + const safeTitle = c.title.replace(/\n/g, ' ').trim() || '사용자 정정'; + return [ + '---', + 'type: lesson', + `title: ${safeTitle}`, + `error-tag: ${c.errorTag}`, + 'applies-to: []', + 'severity: medium', + 'source: user-correction', + 'occurrences: 1', + `last-seen: ${today}`, + '---', + '', + `# Lesson: ${safeTitle}`, + '', + '## Situation', + `사용자 질문: ${c.question}`, + '', + '## Mistake / Risk', + `[${c.errorTag}] AI 답변: ${c.wrongAnswer}`, + '', + '## Fix', + `사용자 정정 (Ground Truth): ${c.correction}`, + '', + '## Prevention Checklist', + `- 같은 질문 유형에서 [${c.errorTag}] 재발 여부 확인 — 주간 회귀 테스트 대상`, + '', + ].join('\n'); +} + +/** + * 정정 1건 캡처 — 분류 → 레슨 저장 + 회귀 케이스 적립. fire-and-forget 용 + * (실패는 로그 대상이지 사용자 턴을 막지 않는다). 저장된 레슨 경로 또는 null 반환. + */ +export async function captureCorrection(opts: { + brainPath: string; + question: string; + wrongAnswer: string; + correction: string; + llm: { baseUrl: string; model: string }; +}): Promise { + const { tag, title } = await classifyCorrection(opts.question, opts.wrongAnswer, opts.correction, opts.llm); + const now = new Date(); + const c: CorrectionCase = { + ts: now.toISOString(), + errorTag: tag, + question: opts.question, + wrongAnswer: opts.wrongAnswer, + correction: opts.correction, + title, + }; + appendCorrectionCase(opts.brainPath, c); + try { + const dir = path.join(opts.brainPath, 'lessons'); + fs.mkdirSync(dir, { recursive: true }); + const ymd = now.toISOString().slice(0, 10); + const slug = title.toLowerCase().replace(/[^a-z0-9가-힣]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 50) || 'correction'; + const file = path.join(dir, `${ymd}-correction-${slug}.md`); + fs.writeFileSync(file, correctionLessonMarkdown(c, ymd), 'utf8'); + return file; + } catch { + return null; + } +} + +// ── ②③ 약점 프로필 — 태그 통계를 다음 턴의 행동으로 변환 ──────────────────── + +export interface WeaknessProfile { + updatedAt: string; + totalCases: number; + /** 최근 윈도우의 태그별 건수 (내림차순). */ + tagCounts: Array<{ tag: string; count: number; example: string }>; +} + +/** 최근 windowDays 의 정정 케이스에서 약점 프로필 산출 (성장 사이클이 주간 호출). */ +export function computeWeaknessProfile(cases: CorrectionCase[], nowMs: number, windowDays = 60): WeaknessProfile { + const cutoff = nowMs - windowDays * 86_400_000; + const recent = cases.filter(c => { + const t = Date.parse(c.ts); + return Number.isFinite(t) && t >= cutoff; + }); + const byTag = new Map(); + for (const c of recent) { + const cur = byTag.get(c.errorTag) || { count: 0, example: '' }; + cur.count++; + cur.example = c.title; // 최신 케이스 제목을 예시로 + byTag.set(c.errorTag, cur); + } + return { + updatedAt: new Date(nowMs).toISOString(), + totalCases: recent.length, + tagCounts: Array.from(byTag.entries()) + .map(([tag, v]) => ({ tag, count: v.count, example: v.example })) + .sort((a, b) => b.count - a.count), + }; +} + +export function saveWeaknessProfile(brainPath: string, profile: WeaknessProfile): boolean { + try { + const file = path.join(brainPath, WEAKNESS_PROFILE_REL_PATH); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, JSON.stringify(profile, null, 2) + '\n', 'utf8'); + return true; + } catch { + return false; + } +} + +export function loadWeaknessProfile(brainPath: string): WeaknessProfile | null { + try { + const file = path.join(brainPath, WEAKNESS_PROFILE_REL_PATH); + if (!fs.existsSync(file)) return null; + const o = JSON.parse(fs.readFileSync(file, 'utf8')); + return o && Array.isArray(o.tagCounts) ? o as WeaknessProfile : null; + } catch { + return null; + } +} + +/** + * 약점 프로필 → 시스템 프롬프트 자기검토 블록. 같은 태그 2회 이상일 때만 주입 + * (1회성 실수로 프롬프트를 어지럽히지 않게). 프로필 없으면 ''. + */ +export function buildSelfReviewBlock(profile: WeaknessProfile | null): string { + if (!profile) return ''; + const significant = profile.tagCounts.filter(t => t.count >= 2).slice(0, 2); + if (significant.length === 0) return ''; + const lines = ['[자기검토 — 최근 정정 통계 기반]']; + for (const t of significant) { + lines.push(`- 너는 최근 "${t.tag}" 정정을 ${t.count}회 받았다 (예: ${t.example}). ${SELF_CHECK_BY_TAG[t.tag] || '같은 유형의 실수가 없는지 답하기 전 재확인하라.'}`); + } + return lines.join('\n'); +} + +const SELF_CHECK_BY_TAG: Record = { + '사실오류': '수치·날짜·고유명사는 두뇌 근거가 없으면 단정하지 말고 "확인 필요"로 표시하라.', + '근거누락': '주장마다 근거 문서를 인용하고, 인용할 수 없으면 추정임을 명시하라.', + '맥락누락': '답하기 전 직전 대화와 제공된 문서에서 관련 맥락을 다시 확인하라.', + '추론오류': '결론 전에 추론 단계를 명시적으로 나열하고 비약이 없는지 점검하라.', + '지시불이행': '답하기 전 사용자 요구사항 목록을 만들고 각각 충족했는지 확인하라.', + '형식오류': '요구된 출력 형식(언어·구조·길이)을 답변 전에 재확인하라.', +}; + +// ── ③ 지식 공백 → 학습 큐 자동 proposed 등록 (Need Engine 연결) ────────────── + +/** + * GROUNDING 약함으로 판정된 질문을 학습 큐에 proposed 로 등록한다. 같은 질문은 + * 1회만 (해시 id 중복 차단 — done/rejected 포함 어떤 상태든 재등록 안 함). + * 승인은 사람 (Permission Based Learning). 새로 등록됐으면 true. + */ +export function registerKnowledgeGap(brainPath: string, question: string, topScore: number): boolean { + const q = (question || '').trim(); + if (q.length < 10) return false; + try { + const norm = q.toLowerCase().replace(/\s+/g, ' ').slice(0, 200); + let h = 5381; + for (let i = 0; i < norm.length; i++) h = ((h << 5) + h + norm.charCodeAt(i)) | 0; + const id = `gap-${(h >>> 0).toString(36)}`; + const queue = loadQueue(brainPath); + if (queue.some(item => item.id === id)) return false; + // 폭주 방지 — gap 제안이 20건 쌓여 있으면 사람이 정리할 때까지 추가 등록 중단. + if (queue.filter(item => item.id.startsWith('gap-') && item.status === 'proposed').length >= 20) return false; + const nowIso = new Date().toISOString(); + queue.push({ + id, + topic: `지식 공백: ${q.slice(0, 80)}`, + priority: 40, + reason: `대화 중 GROUNDING 약함 자동 감지 (두뇌 최고 점수 ${topScore.toFixed(2)})`, + status: 'proposed', + createdAt: nowIso, + updatedAt: nowIso, + }); + saveQueue(brainPath, queue); + return true; + } catch { + return false; + } +} + +// ── ② 주간 회귀 테스트 — "정정받은 질문에서 같은 실수를 반복하는가" ────────── + +export interface RegressionResult { + question: string; + errorTag: ErrorTag; + repeated: boolean | null; // null = 판정 실패 + note: string; +} + +const REGRESSION_JUDGE_SYSTEM = [ + '너는 회귀 판정기다. 과거에 사용자가 AI 답변의 오류를 정정했다.', + '같은 질문에 대한 AI 의 *새 답변*이 같은 오류를 반복하는지 판정하라.', + '반드시 JSON 한 줄만 출력: {"repeated":true|false,"note":"<근거 30자>"}', +].join('\n'); + +/** + * 회귀 케이스 1건 재검사. answerFn 은 호출부가 주입 (성장 사이클: 두뇌 검색 컨텍스트 + * 포함 LLM 호출). LLM-judge 가 정정 내용 대비 재발 여부를 판정. + */ +export async function runRegressionCase( + c: CorrectionCase, + answerFn: (question: string) => Promise, + llm: { baseUrl: string; model: string }, +): Promise { + try { + const newAnswer = (await answerFn(c.question)).slice(0, 1500); + const user = [ + `[질문] ${c.question}`, + `[과거 오류 (${c.errorTag})] ${c.wrongAnswer}`, + `[사용자 정정 (Ground Truth)] ${c.correction}`, + `[새 답변] ${newAnswer}`, + ].join('\n'); + const raw = await simpleChatCompletion(REGRESSION_JUDGE_SYSTEM, user, { + baseUrl: llm.baseUrl, model: llm.model, temperature: 0.1, maxTokens: 100, timeoutMs: 60000, + }); + const m = raw.match(/\{[\s\S]*?\}/); + if (!m) return { question: c.question, errorTag: c.errorTag, repeated: null, note: '판정 파싱 실패' }; + const parsed = JSON.parse(m[0]); + return { + question: c.question, + errorTag: c.errorTag, + repeated: parsed?.repeated === true, + note: String(parsed?.note || '').slice(0, 60), + }; + } catch (e: any) { + return { question: c.question, errorTag: c.errorTag, repeated: null, note: `실패: ${(e?.message ?? e)}`.slice(0, 60) }; + } +} + +export function formatRegressionReport(results: RegressionResult[], meta: { dateStr: string }): string { + const lines = [`# 정정 회귀 리포트 — ${meta.dateStr}`, '']; + lines.push('과거 사용자 정정(Ground Truth)을 같은 질문으로 재검사한 결과.'); + lines.push(''); + lines.push('| 결과 | 유형 | 질문 | 비고 |'); + lines.push('|---|---|---|---|'); + for (const r of results) { + const mark = r.repeated === true ? '❌ 재발' : r.repeated === false ? '✅ 통과' : '⚠️ 판정불가'; + lines.push(`| ${mark} | ${r.errorTag} | ${r.question.slice(0, 60).replace(/\|/g, '/')} | ${r.note.replace(/\|/g, '/')} |`); + } + return lines.join('\n'); +} diff --git a/src/lib/contextBuilders/memoryContext.ts b/src/lib/contextBuilders/memoryContext.ts index f00b158..778951c 100644 --- a/src/lib/contextBuilders/memoryContext.ts +++ b/src/lib/contextBuilders/memoryContext.ts @@ -8,6 +8,7 @@ import type { RetrievalOrchestrator } from '../../retrieval'; import { buildLessonChecklistBlock } from '../../retrieval/lessonHelpers'; import { embedQuery, embedTexts } from '../../retrieval/embeddings'; import { backfillBrainEmbeddings, backfillBrainChunkEmbeddings } from '../../retrieval/brainIndex'; +import { loadWeaknessProfile, buildSelfReviewBlock, registerKnowledgeGap } from '../../intelligence/correctionLoop'; import { resolveScopeForAgent } from '../../skills/agentKnowledgeMap'; import { resolveKnowledgeMix, @@ -196,7 +197,7 @@ export async function buildMemoryContext(deps: MemoryContextDeps): Promise 0 && assessGrounding(result).level === 'weak') { + try { + const retry = deps.retrievalOrchestrator.retrieve(deps.currentPrompt, { ...retrieveOpts, scopeFolders: [] }); + if (assessGrounding(retry).level !== 'weak') { + retry.fusionLog.push('Grounding rescue: scope 해제 재검색 채택 (약함 → ' + assessGrounding(retry).level + ')'); + result = retry; + } + } catch { /* 재검색 실패는 원 결과로 진행 */ } + } // Semantic Re-rank (LLM, async) — selectedChunks 의 *순서* 만 재배치. 토큰 예산을 // 통과한 chunks 안에서 의도-부합도 순으로 재정렬해 LLM attention bias 활용. @@ -380,8 +394,22 @@ export async function buildMemoryContext(deps: MemoryContextDeps): Promise }): string { +interface GroundingAssessment { level: 'strong' | 'moderate' | 'weak'; top: number; count: number } + +function assessGrounding(result: { selectedChunks: Array<{ source: string; score: number }> }): GroundingAssessment { const brainChunks = result.selectedChunks.filter((c) => c.source === 'brain-trace' || c.source === 'brain-memory'); const top = brainChunks.length ? Math.max(...brainChunks.map((c) => c.score || 0)) : 0; - let level: 'strong' | 'moderate' | 'weak'; + let level: GroundingAssessment['level']; if (brainChunks.length === 0 || top < 0.25) level = 'weak'; else if (top >= 0.5 && brainChunks.length >= 2) level = 'strong'; else level = 'moderate'; + return { level, top, count: brainChunks.length }; +} - const lines = [`[GROUNDING] 이번 질의의 두뇌 근거 강도: ${level === 'strong' ? '강함' : level === 'moderate' ? '보통' : '약함'} (두뇌 청크 ${brainChunks.length}개, 최고 점수 ${top.toFixed(2)})`]; +function buildGroundingBlock({ level, top, count }: GroundingAssessment): string { + const lines = [`[GROUNDING] 이번 질의의 두뇌 근거 강도: ${level === 'strong' ? '강함' : level === 'moderate' ? '보통' : '약함'} (두뇌 청크 ${count}개, 최고 점수 ${top.toFixed(2)})`]; if (level === 'weak') { lines.push('→ 답변 첫 줄에 "⚠️ 두뇌 근거 약함 — 일반 지식 기반 추정입니다." 를 표기하고, 단정 대신 "가능성/추정" 표현을 사용하라. 확실하지 않은 세부 수치·고유명사는 만들지 말 것.'); } else if (level === 'strong') { diff --git a/tests/correctionLoop.test.ts b/tests/correctionLoop.test.ts new file mode 100644 index 0000000..4d26184 --- /dev/null +++ b/tests/correctionLoop.test.ts @@ -0,0 +1,158 @@ +/** + * Correction Loop 단위 테스트 — 순수 로직 (감지·프로필·레슨·큐 등록·영속화). + * LLM 의존 부분(classifyCorrection)은 엔드포인트 실패 → 휴리스틱 fallback 경로만 검증. + */ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { + looksLikeCorrection, classifyCorrection, correctionLessonMarkdown, + appendCorrectionCase, loadCorrectionCases, registerKnowledgeGap, + computeWeaknessProfile, saveWeaknessProfile, loadWeaknessProfile, + buildSelfReviewBlock, formatRegressionReport, + type CorrectionCase, +} from '../src/intelligence/correctionLoop'; +import { loadQueue } from '../src/intelligence/learningQueue'; + +const tmpBrain = () => fs.mkdtempSync(path.join(os.tmpdir(), 'astra-corr-')); + +const CASE = (over: Partial = {}): CorrectionCase => ({ + ts: new Date().toISOString(), + errorTag: '사실오류', + question: '회의가 언제였지?', + wrongAnswer: '5월 10일입니다.', + correction: '아니야, 6월 10일이야.', + title: '회의 날짜 오답', + ...over, +}); + +describe('looksLikeCorrection — 정정 감지 (보수적)', () => { + test.each([ + '아니야, 그 회의는 6월이야', + '틀렸어. 다시 확인해', + '그게 아니라 화요일이야', + '담당자는 김OO가 아니라 박OO야', + '그 수치는 사실과 달라', + '잘못 알고 있네 — 정정해줄게', + '지어내지 마', + ])('정정으로 감지: %s', (p) => expect(looksLikeCorrection(p)).toBe(true)); + + test.each([ + '커밋하고 푸쉬해줘', + '내일 일정 알려줘', + '아니 그래서 결과가 어떻게 됐어?', // "아니" 단독 추임새는 비정정 + '회의록 요약해줘', + '', + '응', + ])('비정정: %s', (p) => expect(looksLikeCorrection(p)).toBe(false)); +}); + +describe('classifyCorrection — LLM 실패 시 휴리스틱 fallback', () => { + const deadLlm = { baseUrl: 'http://127.0.0.1:1', model: 'x' }; + test('출처 언급 → 근거누락', async () => { + const r = await classifyCorrection('q', 'a', '출처도 없이 단정하지 마', deadLlm); + expect(r.tag).toBe('근거누락'); + expect(r.title.length).toBeGreaterThan(0); + }); + test('맥락 언급 → 맥락누락', async () => { + const r = await classifyCorrection('q', 'a', '아까 위에서 말했잖아', deadLlm); + expect(r.tag).toBe('맥락누락'); + }); + test('기본 → 사실오류', async () => { + const r = await classifyCorrection('q', 'a', '6월 10일이 맞아', deadLlm); + expect(r.tag).toBe('사실오류'); + }); +}); + +describe('회귀 케이스 영속화', () => { + test('append → load 라운드트립 + 필드 길이 제한', () => { + const brain = tmpBrain(); + const long = 'x'.repeat(2000); + expect(appendCorrectionCase(brain, CASE({ wrongAnswer: long }))).toBe(true); + expect(appendCorrectionCase(brain, CASE({ title: '두번째' }))).toBe(true); + const cases = loadCorrectionCases(brain); + expect(cases).toHaveLength(2); + expect(cases[0].wrongAnswer.length).toBeLessThanOrEqual(600); + expect(cases[1].title).toBe('두번째'); + }); + test('손상 라인은 건너뛴다', () => { + const brain = tmpBrain(); + appendCorrectionCase(brain, CASE()); + fs.appendFileSync(path.join(brain, '.astra', 'eval', 'corrections.jsonl'), '{broken json\n'); + appendCorrectionCase(brain, CASE({ title: 'b' })); + expect(loadCorrectionCases(brain)).toHaveLength(2); + }); +}); + +describe('correctionLessonMarkdown', () => { + test('error-tag frontmatter + Ground Truth 포함', () => { + const md = correctionLessonMarkdown(CASE(), '2026-06-11'); + expect(md).toContain('error-tag: 사실오류'); + expect(md).toContain('source: user-correction'); + expect(md).toContain('아니야, 6월 10일이야.'); + expect(md).toMatch(/^---\ntype: lesson/); + }); +}); + +describe('registerKnowledgeGap — 학습 큐 자동 proposed', () => { + test('등록 + 같은 질문 중복 차단', () => { + const brain = tmpBrain(); + expect(registerKnowledgeGap(brain, 'CRAG 교정 검색이 뭐야?', 0.12)).toBe(true); + expect(registerKnowledgeGap(brain, 'CRAG 교정 검색이 뭐야?', 0.2)).toBe(false); + const q = loadQueue(brain); + expect(q).toHaveLength(1); + expect(q[0].status).toBe('proposed'); + expect(q[0].id).toMatch(/^gap-/); + expect(q[0].reason).toContain('0.12'); + }); + test('짧은 질문 무시 + 20건 폭주 방지', () => { + const brain = tmpBrain(); + expect(registerKnowledgeGap(brain, '뭐야?', 0)).toBe(false); + for (let i = 0; i < 25; i++) registerKnowledgeGap(brain, `지식 공백 질문 번호 ${i} 에 대한 상세 내용`, 0.1); + expect(loadQueue(brain).length).toBeLessThanOrEqual(20); + }); +}); + +describe('약점 프로필', () => { + const now = Date.parse('2026-06-11T00:00:00Z'); + test('윈도우 필터 + 태그 집계 내림차순', () => { + const cases = [ + CASE({ ts: '2026-06-01T00:00:00Z', errorTag: '사실오류' }), + CASE({ ts: '2026-06-05T00:00:00Z', errorTag: '사실오류', title: '최신 예시' }), + CASE({ ts: '2026-06-03T00:00:00Z', errorTag: '근거누락' }), + CASE({ ts: '2025-01-01T00:00:00Z', errorTag: '형식오류' }), // 윈도우 밖 + ]; + const p = computeWeaknessProfile(cases, now, 60); + expect(p.totalCases).toBe(3); + expect(p.tagCounts[0]).toMatchObject({ tag: '사실오류', count: 2, example: '최신 예시' }); + expect(p.tagCounts.find(t => t.tag === '형식오류')).toBeUndefined(); + }); + test('save → load 라운드트립 + 자기검토 블록 (2회 이상만)', () => { + const brain = tmpBrain(); + const p = computeWeaknessProfile([ + CASE({ errorTag: '사실오류' }), CASE({ errorTag: '사실오류' }), CASE({ errorTag: '근거누락' }), + ], Date.now(), 60); + expect(saveWeaknessProfile(brain, p)).toBe(true); + const block = buildSelfReviewBlock(loadWeaknessProfile(brain)); + expect(block).toContain('[자기검토'); + expect(block).toContain('사실오류'); + expect(block).not.toContain('근거누락'); // 1회짜리는 미주입 + }); + test('데이터 없으면 빈 블록', () => { + expect(buildSelfReviewBlock(null)).toBe(''); + expect(buildSelfReviewBlock(computeWeaknessProfile([], Date.now()))).toBe(''); + }); +}); + +describe('formatRegressionReport', () => { + test('재발/통과/판정불가 마크', () => { + const md = formatRegressionReport([ + { question: 'q1', errorTag: '사실오류', repeated: true, note: 'n1' }, + { question: 'q2', errorTag: '근거누락', repeated: false, note: 'n2' }, + { question: 'q3', errorTag: '기타', repeated: null, note: 'n3' }, + ], { dateStr: '2026-06-11' }); + expect(md).toContain('❌ 재발'); + expect(md).toContain('✅ 통과'); + expect(md).toContain('⚠️ 판정불가'); + }); +});