feat(ai): prompt 에 notebooks 목록 + schema 의 notebook_match 필드

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
th-kim0823
2026-05-15 10:34:11 +09:00
parent 14ab135425
commit 359d94e7e6
8 changed files with 77 additions and 26 deletions

View File

@@ -14,7 +14,7 @@ function makeProvider(overrides: Partial<InferenceProvider> = {}): InferenceProv
return {
name: 'mock',
generate: vi.fn(async (): Promise<AiResponse> => ({
title: '제목', summary: 'a\nb\nc', tags: ['tag'], dueDate: null
title: '제목', summary: 'a\nb\nc', tags: ['tag'], dueDate: null, notebookMatch: null
})),
healthCheck: vi.fn(async () => ({ ok: true })),
...overrides
@@ -77,7 +77,7 @@ describe('AiWorker', () => {
running++; max = Math.max(max, running);
await new Promise((r) => setTimeout(r, 10));
running--;
return { title: '제목', summary: 'a\nb\nc', tags: [], dueDate: null };
return { title: '제목', summary: 'a\nb\nc', tags: [], dueDate: null, notebookMatch: null };
})
});
const w = new AiWorker(repo, new ProviderHolder(provider), { backoffsMs: [0, 0, 0] });
@@ -137,7 +137,8 @@ describe('AiWorker', () => {
title: '아무 메모',
summary: 'a\nb\nc',
tags: [],
dueDate: null
dueDate: null,
notebookMatch: null
}),
healthCheck: async () => ({ ok: true })
} as any;
@@ -159,7 +160,7 @@ describe('AiWorker', () => {
generate: async (input: any) => {
seen.todayKst = input.todayKst;
seen.dueDateCandidates = input.dueDateCandidates;
return { title: '메모', summary: 'a\nb\nc', tags: [], dueDate: null };
return { title: '메모', summary: 'a\nb\nc', tags: [], dueDate: null, notebookMatch: null };
},
healthCheck: async () => ({ ok: true })
} as any;
@@ -181,7 +182,7 @@ describe('AiWorker', () => {
name: 'mock',
generate: async (input: any) => {
captured = input;
return { title: '내일', summary: 'a\nb\nc', tags: [], dueDate: null };
return { title: '내일', summary: 'a\nb\nc', tags: [], dueDate: null, notebookMatch: null };
},
healthCheck: async () => ({ ok: true })
} as any;
@@ -409,7 +410,7 @@ describe('AiWorker — unreachable/timeout infinite retry (v0.2.3 #2)', () => {
generate: vi.fn(async (): Promise<AiResponse> => {
callCount += 1;
if (callCount <= 2) throw new Error('ECONNREFUSED');
return { title: 't', summary: 's', tags: [], dueDate: null };
return { title: 't', summary: 's', tags: [], dueDate: null, notebookMatch: null };
})
});
const w = new AiWorker(repo, new ProviderHolder(provider), {
@@ -442,7 +443,7 @@ describe('AiWorker — vocab fetch + per-tag hit/miss (v0.2.3 #3 T7)', () => {
const { id } = repo.create({ rawText: 'x' });
const generateMock = vi.fn(async () => ({
title: '제목', summary: 'a\nb\nc', tags: ['design'], dueDate: null
title: '제목', summary: 'a\nb\nc', tags: ['design'], dueDate: null, notebookMatch: null
}));
const w = new AiWorker(repo, new ProviderHolder(makeProvider({ generate: generateMock })), {
backoffsMs: [0, 0, 0]
@@ -465,7 +466,8 @@ describe('AiWorker — vocab fetch + per-tag hit/miss (v0.2.3 #3 T7)', () => {
generate: vi.fn(async () => ({
title: 't', summary: 'a\nb\nc',
tags: ['design', 'newtag'], // 1 hit + 1 miss
dueDate: null
dueDate: null,
notebookMatch: null
}))
});
const emits: EmittedEvent[] = [];
@@ -495,7 +497,8 @@ describe('AiWorker — vocab fetch + per-tag hit/miss (v0.2.3 #3 T7)', () => {
generate: vi.fn(async () => ({
title: 't', summary: 'a\nb\nc',
tags: ['design', 'meeting', 'qa'],
dueDate: null
dueDate: null,
notebookMatch: null
}))
});
const emits: EmittedEvent[] = [];
@@ -520,7 +523,8 @@ describe('AiWorker — vocab fetch + per-tag hit/miss (v0.2.3 #3 T7)', () => {
generate: vi.fn(async () => ({
title: 't', summary: 'a\nb\nc',
tags: ['design', 'meeting', 'qa'],
dueDate: null
dueDate: null,
notebookMatch: null
}))
});
const emits: EmittedEvent[] = [];
@@ -544,7 +548,8 @@ describe('AiWorker — vocab fetch + per-tag hit/miss (v0.2.3 #3 T7)', () => {
generate: vi.fn(async () => ({
title: 't', summary: 'a\nb\nc',
tags: ['design', 'design', 'meeting'], // 중복 'design' 의도적
dueDate: null
dueDate: null,
notebookMatch: null
}))
});
const emits: EmittedEvent[] = [];
@@ -581,7 +586,8 @@ describe('vocab COLLATE NOCASE', () => {
generate: vi.fn(async () => ({
title: 't', summary: 'a\nb\nc',
tags: ['Design'], // AI returns capitalized — DB COLLATE NOCASE matches 'design'
dueDate: null
dueDate: null,
notebookMatch: null
}))
});
const emits: EmittedEvent[] = [];
@@ -610,7 +616,8 @@ describe('vocab COLLATE NOCASE', () => {
generate: vi.fn(async () => ({
title: 't', summary: 'a\nb\nc',
tags: ['design'], // AI returns lowercase — DB COLLATE NOCASE matches 'Design'
dueDate: null
dueDate: null,
notebookMatch: null
}))
});
const emits: EmittedEvent[] = [];
@@ -634,7 +641,8 @@ describe('vocab COLLATE NOCASE', () => {
generate: vi.fn(async () => ({
title: 't', summary: 'a\nb\nc',
tags: ['design'], // same lowercase — should still hit
dueDate: null
dueDate: null,
notebookMatch: null
}))
});
const emits: EmittedEvent[] = [];

View File

@@ -65,7 +65,7 @@ describe('AiWorker — vision path (v0.3.1 Cut F)', () => {
opts?: Parameters<InferenceProvider['generate']>[1]
): Promise<AiResponse> => {
calls.push([input, opts]);
return { title: 't', summary: 'a\nb\nc', tags: [], dueDate: null };
return { title: 't', summary: 'a\nb\nc', tags: [], dueDate: null, notebookMatch: null };
});
const getVisionModel = vi.fn(async (): Promise<string | null> => 'gemma3:12b-vision');
const worker = makeWorker(generate, getVisionModel);
@@ -87,7 +87,7 @@ describe('AiWorker — vision path (v0.3.1 Cut F)', () => {
opts?: Parameters<InferenceProvider['generate']>[1]
): Promise<AiResponse> => {
calls.push([input, opts]);
return { title: 't', summary: 'a\nb\nc', tags: [], dueDate: null };
return { title: 't', summary: 'a\nb\nc', tags: [], dueDate: null, notebookMatch: null };
});
const getVisionModel = vi.fn(async (): Promise<string | null> => null);
const worker = makeWorker(generate, getVisionModel);
@@ -110,7 +110,7 @@ describe('AiWorker — vision path (v0.3.1 Cut F)', () => {
opts?: Parameters<InferenceProvider['generate']>[1]
): Promise<AiResponse> => {
calls.push([input, opts]);
return { title: 't', summary: 'a\nb\nc', tags: [], dueDate: null };
return { title: 't', summary: 'a\nb\nc', tags: [], dueDate: null, notebookMatch: null };
});
const getVisionModel = vi.fn(async (): Promise<string | null> => 'gemma4:26b');
const worker = makeWorker(generate, getVisionModel);
@@ -136,7 +136,7 @@ describe('AiWorker — vision path (v0.3.1 Cut F)', () => {
{ noteId: id, kind: 'image', relPath: `media/${id}/1.png`, mime: 'image/png', bytes: 1 },
{ noteId: id, kind: 'image', relPath: `media/${id}/2.png`, mime: 'image/png', bytes: 1 }
]);
const generate = vi.fn(async (): Promise<AiResponse> => ({ title: 't', summary: 'a\nb\nc', tags: [], dueDate: null }));
const generate = vi.fn(async (): Promise<AiResponse> => ({ title: 't', summary: 'a\nb\nc', tags: [], dueDate: null, notebookMatch: null }));
const getVisionModel = vi.fn(async (): Promise<string | null> => 'gemma4:26b');
const worker = makeWorker(generate, getVisionModel);
await worker.enqueue(id);
@@ -157,7 +157,7 @@ describe('AiWorker — vision path (v0.3.1 Cut F)', () => {
opts?: Parameters<InferenceProvider['generate']>[1]
): Promise<AiResponse> => {
calls.push([input, opts]);
return { title: 't', summary: 'a\nb\nc', tags: [], dueDate: null };
return { title: 't', summary: 'a\nb\nc', tags: [], dueDate: null, notebookMatch: null };
});
const getVisionModel = vi.fn(async (): Promise<string | null> => 'gemma3:12b-vision');
const worker = makeWorker(generate, getVisionModel);

View File

@@ -112,4 +112,19 @@ describe('parseAiResponse', () => {
});
expect(r.dueDate).toBeNull();
});
it('parseAiResponse — notebook_match valid string', () => {
const r = parseAiResponse({ title: '제목입니다', summary: '한\n두\n셋', tags: [], due_date: null, notebook_match: '회사' });
expect(r.notebookMatch).toBe('회사');
});
it('parseAiResponse — notebook_match null', () => {
const r = parseAiResponse({ title: '제목입니다', summary: '한\n두\n셋', tags: [], due_date: null, notebook_match: null });
expect(r.notebookMatch).toBeNull();
});
it('parseAiResponse — notebook_match 누락 시 null', () => {
const r = parseAiResponse({ title: '제목입니다', summary: '한\n두\n셋', tags: [], due_date: null });
expect(r.notebookMatch).toBeNull();
});
});

View File

@@ -29,3 +29,18 @@ describe('prompt', () => {
expect(jsonRulesIdx).toBeGreaterThan(vocabIdx);
});
});
describe('buildPrompt with notebooks', () => {
it('notebooks 목록이 prompt 에 포함됨', () => {
const p = buildPrompt('hi', '2026-05-14', [], [], ['기본', '회사']);
expect(p).toContain('기본');
expect(p).toContain('회사');
expect(p).toContain('notebook_match');
});
it('notebooks 빈 배열 시 notebook 섹션 생략', () => {
const p = buildPrompt('hi', '2026-05-14', [], [], []);
expect(p).not.toContain('notebook_match');
expect(p).not.toContain('사용 가능한 노트북');
});
});