feat(ai): batch classify — default notebook 의 노트들 일괄 fit 매칭 (단일 prompt)
This commit is contained in:
212
tests/unit/batchClassify.test.ts
Normal file
212
tests/unit/batchClassify.test.ts
Normal file
@@ -0,0 +1,212 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { batchClassifyDefault } from '../../src/main/ai/batchClassify';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Minimal fakes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface FakeNote { id: string; rawText: string; aiTitle: string | null; notebookId: string; status: string }
|
||||
interface FakeNotebook { id: string; name: string }
|
||||
|
||||
function makeNoteRepo(notes: FakeNote[], defaultNotebookId: string) {
|
||||
return {
|
||||
listByStatus: vi.fn((_status: string, opts: { notebookId?: string; limit?: number } = {}) => {
|
||||
if (opts.notebookId !== defaultNotebookId) return [];
|
||||
const limit = opts.limit ?? notes.length;
|
||||
return notes.slice(0, limit);
|
||||
}),
|
||||
getDefaultNotebookId: vi.fn(() => defaultNotebookId)
|
||||
};
|
||||
}
|
||||
|
||||
function makeNotebookRepo(notebooks: FakeNotebook[]) {
|
||||
return {
|
||||
getDefault: vi.fn(() => notebooks[0] ?? null),
|
||||
list: vi.fn(() => notebooks),
|
||||
findByName: vi.fn((name: string) => notebooks.find((n) => n.name.toLowerCase() === name.toLowerCase()) ?? null)
|
||||
};
|
||||
}
|
||||
|
||||
function makeProvider(json: string) {
|
||||
return {
|
||||
generateRaw: vi.fn(async (_p: string) => json)
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('batchClassifyDefault', () => {
|
||||
it('default notebook 의 노트들 + 다른 notebook 들 prompt 구성 및 결과 매핑', async () => {
|
||||
const defaultNb: FakeNotebook = { id: 'nb-default', name: '기본' };
|
||||
const otherNb: FakeNotebook = { id: 'nb-work', name: '회사' };
|
||||
const notes: FakeNote[] = [
|
||||
{ id: 'n1', rawText: 'MLX 계정 생성 가이드', aiTitle: 'MLX 가이드', notebookId: 'nb-default', status: 'active' },
|
||||
{ id: 'n2', rawText: '미용실 예약', aiTitle: null, notebookId: 'nb-default', status: 'active' }
|
||||
];
|
||||
|
||||
const aiJson = JSON.stringify({
|
||||
assignments: [
|
||||
{ id: 'n1', notebook: '회사' },
|
||||
{ id: 'n2', notebook: null }
|
||||
]
|
||||
});
|
||||
|
||||
const result = await batchClassifyDefault({
|
||||
noteRepo: makeNoteRepo(notes, 'nb-default') as never,
|
||||
notebookRepo: makeNotebookRepo([defaultNb, otherNb]) as never,
|
||||
provider: makeProvider(aiJson)
|
||||
});
|
||||
|
||||
expect(result.assignments).toHaveLength(2);
|
||||
|
||||
const n1 = result.assignments.find((a) => a.noteId === 'n1');
|
||||
expect(n1?.notebookId).toBe('nb-work');
|
||||
expect(n1?.notebookName).toBe('회사');
|
||||
|
||||
const n2 = result.assignments.find((a) => a.noteId === 'n2');
|
||||
expect(n2?.notebookId).toBeNull();
|
||||
expect(n2?.notebookName).toBeNull();
|
||||
});
|
||||
|
||||
it('다른 notebook 0개면 빈 결과', async () => {
|
||||
const defaultNb: FakeNotebook = { id: 'nb-default', name: '기본' };
|
||||
const notes: FakeNote[] = [
|
||||
{ id: 'n1', rawText: '노트', aiTitle: null, notebookId: 'nb-default', status: 'active' }
|
||||
];
|
||||
|
||||
const provider = makeProvider('{}');
|
||||
const result = await batchClassifyDefault({
|
||||
noteRepo: makeNoteRepo(notes, 'nb-default') as never,
|
||||
notebookRepo: makeNotebookRepo([defaultNb]) as never,
|
||||
provider
|
||||
});
|
||||
|
||||
expect(result.assignments).toHaveLength(0);
|
||||
expect(result.skippedReason).toBe('no_other_notebooks');
|
||||
// provider 가 호출되면 안 됨
|
||||
expect(provider.generateRaw).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('default 의 노트 0건이면 빈 결과', async () => {
|
||||
const defaultNb: FakeNotebook = { id: 'nb-default', name: '기본' };
|
||||
const otherNb: FakeNotebook = { id: 'nb-work', name: '회사' };
|
||||
|
||||
const provider = makeProvider('{}');
|
||||
const result = await batchClassifyDefault({
|
||||
noteRepo: makeNoteRepo([], 'nb-default') as never,
|
||||
notebookRepo: makeNotebookRepo([defaultNb, otherNb]) as never,
|
||||
provider
|
||||
});
|
||||
|
||||
expect(result.assignments).toHaveLength(0);
|
||||
expect(result.skippedReason).toBe('no_notes');
|
||||
expect(provider.generateRaw).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('AI 가 hallucinate 한 새 notebook 이름은 skip (notebookId=null 매핑)', async () => {
|
||||
const defaultNb: FakeNotebook = { id: 'nb-default', name: '기본' };
|
||||
const otherNb: FakeNotebook = { id: 'nb-work', name: '회사' };
|
||||
const notes: FakeNote[] = [
|
||||
{ id: 'n1', rawText: '테스트 메모', aiTitle: null, notebookId: 'nb-default', status: 'active' }
|
||||
];
|
||||
|
||||
// AI 가 존재하지 않는 notebook 이름 반환
|
||||
const aiJson = JSON.stringify({
|
||||
assignments: [{ id: 'n1', notebook: '없는노트북이름XYZ' }]
|
||||
});
|
||||
|
||||
const result = await batchClassifyDefault({
|
||||
noteRepo: makeNoteRepo(notes, 'nb-default') as never,
|
||||
notebookRepo: makeNotebookRepo([defaultNb, otherNb]) as never,
|
||||
provider: makeProvider(aiJson)
|
||||
});
|
||||
|
||||
expect(result.assignments).toHaveLength(1);
|
||||
const a = result.assignments[0];
|
||||
expect(a?.noteId).toBe('n1');
|
||||
// 매칭 실패 → null
|
||||
expect(a?.notebookId).toBeNull();
|
||||
expect(a?.notebookName).toBeNull();
|
||||
});
|
||||
|
||||
it('AI 응답의 id 가 입력 list 에 없으면 skip', async () => {
|
||||
const defaultNb: FakeNotebook = { id: 'nb-default', name: '기본' };
|
||||
const otherNb: FakeNotebook = { id: 'nb-work', name: '회사' };
|
||||
const notes: FakeNote[] = [
|
||||
{ id: 'n1', rawText: '메모', aiTitle: null, notebookId: 'nb-default', status: 'active' }
|
||||
];
|
||||
|
||||
// AI 가 unknown-id 반환
|
||||
const aiJson = JSON.stringify({
|
||||
assignments: [
|
||||
{ id: 'unknown-id', notebook: '회사' },
|
||||
{ id: 'n1', notebook: '회사' }
|
||||
]
|
||||
});
|
||||
|
||||
const result = await batchClassifyDefault({
|
||||
noteRepo: makeNoteRepo(notes, 'nb-default') as never,
|
||||
notebookRepo: makeNotebookRepo([defaultNb, otherNb]) as never,
|
||||
provider: makeProvider(aiJson)
|
||||
});
|
||||
|
||||
const ids = result.assignments.map((a) => a.noteId);
|
||||
expect(ids).not.toContain('unknown-id');
|
||||
expect(ids).toContain('n1');
|
||||
});
|
||||
|
||||
it('provider 가 invalid JSON 반환 시 빈 결과 + skippedReason', async () => {
|
||||
const defaultNb: FakeNotebook = { id: 'nb-default', name: '기본' };
|
||||
const otherNb: FakeNotebook = { id: 'nb-work', name: '회사' };
|
||||
const notes: FakeNote[] = [
|
||||
{ id: 'n1', rawText: '메모', aiTitle: null, notebookId: 'nb-default', status: 'active' }
|
||||
];
|
||||
|
||||
const result = await batchClassifyDefault({
|
||||
noteRepo: makeNoteRepo(notes, 'nb-default') as never,
|
||||
notebookRepo: makeNotebookRepo([defaultNb, otherNb]) as never,
|
||||
provider: makeProvider('not valid json at all!!!')
|
||||
});
|
||||
|
||||
expect(result.assignments).toHaveLength(0);
|
||||
expect(result.skippedReason).toMatch(/parse_error|ai_error/);
|
||||
});
|
||||
|
||||
it('top N cap (50) 이 적용되어 prompt 에 최대 50개 노트만 포함', async () => {
|
||||
const defaultNb: FakeNotebook = { id: 'nb-default', name: '기본' };
|
||||
const otherNb: FakeNotebook = { id: 'nb-work', name: '회사' };
|
||||
|
||||
// 60개 노트 생성
|
||||
const notes: FakeNote[] = Array.from({ length: 60 }, (_, i) => ({
|
||||
id: `n${i}`,
|
||||
rawText: `메모 ${i}`,
|
||||
aiTitle: null,
|
||||
notebookId: 'nb-default',
|
||||
status: 'active'
|
||||
}));
|
||||
|
||||
// 50개만 assignments 반환
|
||||
const assignments = notes.slice(0, 50).map((n) => ({ id: n.id, notebook: '회사' }));
|
||||
const aiJson = JSON.stringify({ assignments });
|
||||
|
||||
const capturedPrompts: string[] = [];
|
||||
const provider = {
|
||||
generateRaw: vi.fn(async (p: string) => { capturedPrompts.push(p); return aiJson; })
|
||||
};
|
||||
|
||||
await batchClassifyDefault({
|
||||
noteRepo: makeNoteRepo(notes, 'nb-default') as never,
|
||||
notebookRepo: makeNotebookRepo([defaultNb, otherNb]) as never,
|
||||
provider
|
||||
});
|
||||
|
||||
// prompt 에 n50~n59 (cap 이후) 는 포함 안 됨
|
||||
const prompt = capturedPrompts[0] ?? '';
|
||||
// 처음 50개 (n0~n49) 중 일부는 있어야 함
|
||||
expect(prompt).toContain('n0');
|
||||
// n50 이후는 없어야 함
|
||||
expect(prompt).not.toContain('- n50:');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user