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:
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user