feat(ai): prompt 에 notebooks 목록 + schema 의 notebook_match 필드
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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[] = [];
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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('사용 가능한 노트북');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user