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 => { 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(); }); });