feat(core): 자기지식 접지·웹 접근·환경 자가점검 — 할루시네이션 방어 3중화 (v2.2.247)

- Alignment Self-Learning: 자가 조사(질문 전 두뇌 검색)·사용자 답변 두뇌 저장·핵심메시지/프로젝트 컨텍스트 주입 (alignmentResearch.ts 신규)
- 웹 접근: Bridge 폴백 직접 fetch(webFetch.ts 신규)·<fetch_url> 액션 태그·기업 모드 URL/아키텍처 컨텍스트 주입·bare 도메인 인식
- 트리거 버그 수정: startsWith('/') 가 절대경로를 슬래시 명령으로 오인 — 분석 지시·URL 주입 전멸 원인 (회귀 테스트 고정)
- 자기지식 접지: 기능 인벤토리 lazy 재생성·학습 메커니즘 정본 섹션·[인벤토리 대조] 태그 의무화·결정론적 재구현 제안 정정 훅(featureConceptMap.ts 신규)
- 환경 자가점검: HealthCheckMonitor 에 Bridge/두뇌 볼륨/git 자격증명/확장 버전 검사 4종 + readyBar ⚠ 표시
- 두뇌 동기화: 원격 미설정 시 로컬 새로고침 모드·staged 기준 commit 판정·인증 부재 안내
- 기타: outputFormat 기본 markdown(제목 렌더 복구)·레슨/행동제약 truncation 보호 구역 이동·[CONTEXT] 절단 우선순위 재정렬

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
g1nation
2026-06-12 23:46:07 +09:00
parent 553aa0b134
commit a114d968b0
42 changed files with 4178 additions and 2088 deletions
+208
View File
@@ -0,0 +1,208 @@
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import {
gatherEvidenceForQuestions,
selfAnswerQuestions,
saveAlignmentKnowledge,
SELF_RESEARCH_PREFIX,
ALIGNMENT_KNOWLEDGE_DIR,
_parseSelfAnswerJson,
_slugify,
} from '../src/features/company/alignmentResearch';
import { clearBrainTokenIndex } from '../src/retrieval/brainIndex';
import { invalidateBrainFilesCache } from '../src/utils';
import type { IAIService, AIChatResult } from '../src/core/services';
function mkTmpBrain(): string {
return fs.mkdtempSync(path.join(os.tmpdir(), 'astra-alignment-'));
}
function writeMd(brain: string, rel: string, content: string): string {
const p = path.join(brain, rel);
fs.mkdirSync(path.dirname(p), { recursive: true });
fs.writeFileSync(p, content, 'utf8');
return p;
}
function mockAi(content: string, opts?: { throwOnChat?: boolean }): IAIService {
return {
call: async () => content,
chat: async (): Promise<AIChatResult> => {
if (opts?.throwOnChat) throw new Error('connection refused');
return { content, engine: 'lmstudio', model: 'test', empty: !content };
},
};
}
describe('alignmentResearch._parseSelfAnswerJson', () => {
it('parses strict JSON', () => {
const raw = '{"answers":[{"question":"Q1","status":"answered","answer":"A1"}]}';
const parsed = _parseSelfAnswerJson(raw);
expect(parsed).not.toBeNull();
expect(parsed![0]).toEqual({ question: 'Q1', status: 'answered', answer: 'A1' });
});
it('parses fenced JSON with preamble (small-model tolerance)', () => {
const raw = '판정 결과입니다.\n```json\n{"answers":[{"question":"Q1","status":"unanswered","answer":""}]}\n```';
const parsed = _parseSelfAnswerJson(raw);
expect(parsed).not.toBeNull();
expect(parsed![0].status).toBe('unanswered');
});
it('extracts first balanced object from trailing garbage', () => {
const raw = 'note: {"answers":[{"question":"Q","status":"answered","answer":"A"}]} 끝.';
const parsed = _parseSelfAnswerJson(raw);
expect(parsed).not.toBeNull();
expect(parsed![0].answer).toBe('A');
});
it('returns null on garbage / empty', () => {
expect(_parseSelfAnswerJson('')).toBeNull();
expect(_parseSelfAnswerJson('그냥 텍스트')).toBeNull();
expect(_parseSelfAnswerJson('{"answers": "not-an-array"}')).toBeNull();
});
it('coerces unknown status to unanswered and skips entries without question', () => {
const raw = '{"answers":[{"question":"Q1","status":"maybe","answer":"x"},{"status":"answered","answer":"no-q"}]}';
const parsed = _parseSelfAnswerJson(raw);
expect(parsed).not.toBeNull();
expect(parsed!.length).toBe(1);
expect(parsed![0].status).toBe('unanswered');
});
});
describe('alignmentResearch._slugify', () => {
it('preserves Korean, strips path-dangerous chars, caps length', () => {
const s = _slugify('블로그 v3 프로젝트: "수정"해줘? <지금>', 30);
expect(s).not.toMatch(/[\\/:*?"<>|]/);
expect(s.length).toBeLessThanOrEqual(30);
expect(s).toContain('블로그');
});
it('falls back to "alignment" when everything is stripped', () => {
expect(_slugify('???***///', 30)).toBe('alignment');
expect(_slugify('', 30)).toBe('alignment');
});
});
describe('alignmentResearch.gatherEvidenceForQuestions', () => {
let brain: string;
beforeEach(() => { brain = mkTmpBrain(); });
afterEach(() => {
clearBrainTokenIndex(brain);
invalidateBrainFilesCache();
try { fs.rmSync(brain, { recursive: true, force: true }); } catch { /* ignore */ }
});
it('returns empty excerpts for empty brain (no throw)', () => {
const out = gatherEvidenceForQuestions(brain, ['ConnectAI는 무엇인가요?']);
expect(out.length).toBe(1);
expect(out[0].excerpts).toEqual([]);
});
it('returns empty for invalid brain path / no questions (no throw)', () => {
expect(gatherEvidenceForQuestions('', ['Q'])[0].excerpts).toEqual([]);
expect(gatherEvidenceForQuestions('/nonexistent/path/xyz', ['Q'])[0].excerpts).toEqual([]);
expect(gatherEvidenceForQuestions(brain, [])).toEqual([]);
});
it('finds a relevant note and extracts an excerpt', () => {
writeMd(brain, 'ConnectAI 소개.md',
'# ConnectAI 소개\nConnectAI 는 Astra 라는 VS Code 확장 프로젝트입니다. 로컬 LLM 기반 사이드바 어시스턴트.');
writeMd(brain, '무관한 노트.md', '# 김치찌개 레시피\n돼지고기와 김치를 볶는다.');
invalidateBrainFilesCache();
const out = gatherEvidenceForQuestions(brain, ['ConnectAI 프로젝트는 무엇인가요?']);
expect(out[0].excerpts.length).toBeGreaterThan(0);
expect(out[0].excerpts[0].excerpt).toContain('ConnectAI');
});
});
describe('alignmentResearch.selfAnswerQuestions', () => {
const evidence = [
{
question: 'ConnectAI는 무엇인가요?',
excerpts: [{ title: 'ConnectAI 소개', relativePath: 'ConnectAI 소개.md', excerpt: 'Astra VS Code 확장' }],
},
{ question: '예산은 얼마인가요?', excerpts: [] },
];
it('maps answered questions and passes through evidence-less ones', async () => {
const ai = mockAi(JSON.stringify({
answers: [{ question: 'ConnectAI는 무엇인가요?', status: 'answered', answer: 'Astra VS Code 확장 (ConnectAI 소개)' }],
}));
const out = await selfAnswerQuestions(ai, { userPrompt: 'p', evidence });
expect(out.find((a) => a.question === 'ConnectAI는 무엇인가요?')!.answered).toBe(true);
expect(out.find((a) => a.question === '예산은 얼마인가요?')!.answered).toBe(false);
});
it('falls back to all-unanswered on LLM call failure', async () => {
const out = await selfAnswerQuestions(mockAi('', { throwOnChat: true }), { userPrompt: 'p', evidence });
expect(out.every((a) => !a.answered)).toBe(true);
expect(out.length).toBe(2);
});
it('falls back to all-unanswered on unparseable output', async () => {
const out = await selfAnswerQuestions(mockAi('자유 텍스트 답변'), { userPrompt: 'p', evidence });
expect(out.every((a) => !a.answered)).toBe(true);
});
it('skips the LLM entirely when no question has evidence', async () => {
const ai = mockAi('{"answers":[]}');
const chatSpy = jest.spyOn(ai, 'chat');
const out = await selfAnswerQuestions(ai, {
userPrompt: 'p',
evidence: [{ question: 'Q', excerpts: [] }],
});
expect(chatSpy).not.toHaveBeenCalled();
expect(out[0].answered).toBe(false);
});
});
describe('alignmentResearch.saveAlignmentKnowledge', () => {
let brain: string;
beforeEach(() => { brain = mkTmpBrain(); });
afterEach(() => {
try { fs.rmSync(brain, { recursive: true, force: true }); } catch { /* ignore */ }
});
it('saves user-provided answers as a plain note', () => {
const saved = saveAlignmentKnowledge(brain, {
userPrompt: '블로그 v3 개선 작업',
qaList: [{ q: '대상 독자는 누구인가요?', a: '경제·재테크에 관심 있는 30대 직장인 구독자입니다.' }],
});
expect(saved).not.toBeNull();
expect(saved!).toContain(ALIGNMENT_KNOWLEDGE_DIR);
const content = fs.readFileSync(saved!, 'utf8');
expect(content).toContain('## 원본 요청');
expect(content).toContain('30대 직장인');
});
it('filters out self-research entries and short answers', () => {
const saved = saveAlignmentKnowledge(brain, {
userPrompt: 'p',
qaList: [
{ q: 'Q1', a: SELF_RESEARCH_PREFIX + '두뇌에서 이미 확인된 충분히 긴 답변입니다만 저장 제외.' },
{ q: 'Q2', a: '짧음' },
],
});
expect(saved).toBeNull();
expect(fs.existsSync(path.join(brain, ALIGNMENT_KNOWLEDGE_DIR))).toBe(false);
});
it('skips duplicate save for the same day + prompt', () => {
const input = {
userPrompt: '같은 요청',
qaList: [{ q: 'Q', a: '이 답은 스무 글자를 확실히 넘는 사용자 직접 답변입니다.' }],
};
const first = saveAlignmentKnowledge(brain, input);
const second = saveAlignmentKnowledge(brain, input);
expect(first).not.toBeNull();
expect(second).toBeNull();
});
it('returns null for missing brain path (no throw)', () => {
expect(saveAlignmentKnowledge('/nonexistent/path/xyz', {
userPrompt: 'p',
qaList: [{ q: 'Q', a: '충분히 길고 진지한 사용자 답변이 여기 있습니다.' }],
})).toBeNull();
});
});
+90
View File
@@ -0,0 +1,90 @@
/**
* 자기 분석 트리거 + 인벤토리 자동 대조 회귀 테스트.
*
* 모든 케이스는 실제 사용자 세션에서 재현된 실패에서 가져왔다:
* - "/Volumes/... 분석해줘" 프롬프트가 startsWith('/') 가드에 슬래시 명령으로
* 오인되어 분석 지시·인벤토리가 전부 미주입 (v2.2.239 까지의 버그)
* - 이전 답변 전문(5천자+)을 붙여넣는 재검토 프롬프트가 길이 상한에 탈락
* - 모델이 이미 구현된 기능(Reflection/Decay/Correction)을 신규 도입 제안
*/
import { isSelfAssessRequest, isAboutSelf } from '../src/lib/contextBuilders/selfAssessContext';
import { isAnalysisRequest, isSlashCommand } from '../src/lib/contextBuilders/promptDetection';
import { extractUrls } from '../src/features/web/webFetch';
import { detectReimplementedProposals, formatReimplementationFooter } from '../src/extension/featureConceptMap';
// 사용자가 실제로 입력한 프롬프트 원문
const P_PATH_ANALYSIS = '/Volumes/Data/project/Antigravity/ConnectAI 분석하고 어떻게 하면 우리 아스트라를 작업을하면서 스스로 배우고 필요한 지식을 요청해서 사용자로 하여금 해당 지식을 가져오게 할 수 있을까? 의견줘';
const P_LONG_PASTE = '아래 내용 검토해줘. 현재의 자기 진화(self-evolving) 기능은 지식 축적과 구조화 측면에서 ' + '내용 '.repeat(2000);
describe('isSlashCommand — 명령 vs 절대경로 구분', () => {
it('슬래시 명령은 true', () => {
expect(isSlashCommand('/wikify https://a.com')).toBe(true);
expect(isSlashCommand('/benchmark')).toBe(true);
expect(isSlashCommand('/stocks discover')).toBe(true);
});
it('절대경로는 false (v2.2.239 버그 회귀 방지)', () => {
expect(isSlashCommand('/Volumes/Data/project 분석해줘')).toBe(false);
expect(isSlashCommand('/Users/me/code 봐줘')).toBe(false);
});
});
describe('실사용 프롬프트 트리거 (실패 재현 케이스)', () => {
it('절대경로로 시작하는 분석 요청 → 분석 지시 발동', () => {
expect(isAnalysisRequest(P_PATH_ANALYSIS)).toBe(true);
expect(isSelfAssessRequest(P_PATH_ANALYSIS)).toBe(true);
expect(isAboutSelf(P_PATH_ANALYSIS)).toBe(true);
});
it('이전 답변 전문 붙여넣기(5천자+) → 길이 상한 없이 발동', () => {
expect(P_LONG_PASTE.length).toBeGreaterThan(4000);
expect(isSelfAssessRequest(P_LONG_PASTE)).toBe(true);
});
it('자기 작동 방식 질문 ("어떻게 학습해?") → 인벤토리 주입 발동', () => {
expect(isSelfAssessRequest('아스트라 너는 어떻게 학습하고 있어?')).toBe(true);
expect(isSelfAssessRequest('아스트라가 학습하는 방식 설명해줘')).toBe(true);
});
it('절대경로로 시작 + URL 포함 → URL 추출 정상', () => {
expect(extractUrls('/Volumes/Data/proj 보고 koritips.com 도 분석해줘')).toEqual(['https://koritips.com']);
});
});
describe('detectReimplementedProposals — 인벤토리 자동 대조', () => {
it('실제 Astra 답변의 재구현 제안들을 감지', () => {
// 사용자 세션에서 모델이 실제로 출력한 문장 발췌
const answer = [
'개선 제안: handlePrompt 파이프라인 내에 Reflection Layer(성찰 계층)를 추가할 것을 강력히 추천합니다.',
'지식 노후화 감지 시스템 (Knowledge Decay): 축적된 지식이 더 이상 유효하지 않음을 판단하는 메커니즘이 필요합니다.',
'피드백 루프 자동화: 성공/실패의 원인을 분석하여 lessons/ 폴더에 자동으로 교훈으로 저장해야 합니다.',
'자기 검증 및 비판적 사고 루프 (Self-Critique Loop): SelfCritiqueHandler를 추가합니다.',
].join('\n');
const hits = detectReimplementedProposals(answer);
const concepts = hits.map((h) => h.concept);
expect(concepts.some((c) => c.includes('Reflection'))).toBe(true);
expect(concepts.some((c) => c.includes('Decay'))).toBe(true);
expect(concepts.some((c) => c.includes('Self-Critique'))).toBe(true);
const footer = formatReimplementationFooter(hits);
expect(footer).toContain('이미 구현되어 있습니다');
});
it('12B 답변의 변형 표현(Self-Correction/Reasoning Chain)도 감지', () => {
// gemma-4-12b 가 실제로 출력한 문장 발췌 — 키워드 변형으로 정정기를 빗나갔던 케이스
const answer = [
'Reasoning Chain 도입: AgentExecutor 내에 "생각 단계(Thought)"를 명시적으로 분리하는 구조를 강화해야 합니다.',
'Self-Correction 루프: 답변 생성 후 스스로 검토하는 단계를 추가하여 processFinalAnswer 단계에 강화해야 합니다.',
].join('\n');
const hits = detectReimplementedProposals(answer);
const concepts = hits.map((h) => h.concept);
expect(concepts.some((c) => c.includes('Multi-Step') || c.includes('멀티스텝'))).toBe(true);
expect(concepts.some((c) => c.includes('Self-Critique'))).toBe(true);
});
it('이미 구현됨을 인지한 문장은 정정하지 않음 (오탐 방지)', () => {
const answer = 'Reflection Layer는 이미 Self-Reflector 3단계로 구현되어 있으므로, 이를 확장하는 방향을 제안합니다.';
expect(detectReimplementedProposals(answer)).toEqual([]);
});
it('무관한 일반 답변에는 무반응', () => {
const answer = '블로그 글 작성을 위한 SEO 키워드는 다음과 같이 추가해야 합니다: 메인 키워드 1개, 서브 키워드 3개.';
expect(detectReimplementedProposals(answer)).toEqual([]);
expect(formatReimplementationFooter([])).toBe('');
});
});
+76
View File
@@ -0,0 +1,76 @@
import { extractUrls, htmlToText, decodeEntities, fetchUrlDirect } from '../src/features/web/webFetch';
describe('webFetch.extractUrls', () => {
it('extracts http(s) URLs and strips trailing punctuation', () => {
const urls = extractUrls('https://koritips.com 가서 내용 분석해줘.');
expect(urls).toEqual(['https://koritips.com']);
});
it('handles multiple URLs with dedupe and max cap', () => {
const text = 'A: https://a.com/x, B: https://b.com/y 그리고 다시 https://a.com/x 또 https://c.com';
const urls = extractUrls(text, 2);
expect(urls).toEqual(['https://a.com/x', 'https://b.com/y']);
});
it('ignores slash commands and empty input', () => {
expect(extractUrls('/wikify https://a.com')).toEqual([]);
expect(extractUrls('')).toEqual([]);
expect(extractUrls('URL 없는 문장')).toEqual([]);
});
it('strips Korean closing punctuation contamination', () => {
expect(extractUrls('링크(https://a.com/page)를 봐줘')).toEqual(['https://a.com/page']);
expect(extractUrls('「https://b.com」 분석')).toEqual(['https://b.com']);
});
it('recognizes bare domains without scheme and prepends https://', () => {
expect(extractUrls('koritips.com 가서 내용 분석해줘')).toEqual(['https://koritips.com']);
expect(extractUrls('www.example.net/path/page 확인')).toEqual(['https://www.example.net/path/page']);
expect(extractUrls('naver.co.kr 어때?')).toEqual(['https://naver.co.kr']);
});
it('does not mistake filenames or emails for bare domains', () => {
expect(extractUrls('utils.ts 와 package.json 수정해줘')).toEqual([]);
expect(extractUrls('메일은 user@gmail.com 입니다')).toEqual([]);
});
it('does not double-count a scheme URL as a bare domain', () => {
expect(extractUrls('https://koritips.com 그리고 koritips.com')).toEqual(['https://koritips.com']);
});
});
describe('webFetch.htmlToText', () => {
it('strips scripts, styles, and tags while preserving block structure', () => {
const html = `<html><head><style>.x{color:red}</style><script>alert(1)</script></head>
<body><h1>제목입니다</h1><p>첫 문단.</p><p>둘째 문단.</p></body></html>`;
const text = htmlToText(html);
expect(text).not.toContain('alert');
expect(text).not.toContain('color:red');
expect(text).toContain('제목입니다');
expect(text).toMatch(/첫 문단\.\n/);
});
it('decodes common entities', () => {
expect(decodeEntities('A &amp; B &lt;tag&gt; &quot;q&quot; &#39;s&#39; &nbsp;')).toBe(`A & B <tag> "q" 's' `);
expect(decodeEntities('&#44608;')).toBe('김');
});
it('collapses excessive whitespace', () => {
const text = htmlToText('<p>a</p>\n\n\n\n<p>b</p>');
expect(text).not.toMatch(/\n{3,}/);
});
});
describe('webFetch.fetchUrlDirect (no network)', () => {
it('rejects non-http schemes without throwing', async () => {
const r = await fetchUrlDirect('ftp://example.com');
expect(r.ok).toBe(false);
expect(r.error).toContain('http');
});
it('returns honest failure for unreachable host (no throw)', async () => {
const r = await fetchUrlDirect('http://127.0.0.1:1', { timeoutMs: 1500 });
expect(r.ok).toBe(false);
expect(typeof r.error).toBe('string');
}, 10_000);
});