From 359d94e7e6bd1e0b11a76459eec9aa36612a7de6 Mon Sep 17 00:00:00 2001 From: th-kim0823 Date: Fri, 15 May 2026 10:34:11 +0900 Subject: [PATCH] =?UTF-8?q?feat(ai):=20prompt=20=EC=97=90=20notebooks=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=20+=20schema=20=EC=9D=98=20notebook=5Fmatch?= =?UTF-8?q?=20=ED=95=84=EB=93=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- src/main/ai/InferenceProvider.ts | 1 + src/main/ai/LocalOllamaProvider.ts | 2 +- src/main/ai/prompt.ts | 17 ++++++++++---- src/main/ai/schema.ts | 7 ++++-- tests/unit/AiWorker.test.ts | 36 ++++++++++++++++++------------ tests/unit/AiWorker.vision.test.ts | 10 ++++----- tests/unit/ai-schema.test.ts | 15 +++++++++++++ tests/unit/prompt.test.ts | 15 +++++++++++++ 8 files changed, 77 insertions(+), 26 deletions(-) diff --git a/src/main/ai/InferenceProvider.ts b/src/main/ai/InferenceProvider.ts index 5b6ffb9..10baa7d 100644 --- a/src/main/ai/InferenceProvider.ts +++ b/src/main/ai/InferenceProvider.ts @@ -8,6 +8,7 @@ export interface GenerateInput { vocab?: string[]; // v0.2.3 #3 — top-N kebab-case 태그. 미전달 시 빈 배열로 처리. // v0.3.1 Cut F — 첨부 이미지. 미전달 시 텍스트 전용 처리. images?: Array<{ base64: string; mime: string }>; + notebooks?: string[]; // v0.4 Task 8 — 사용 가능한 노트북 목록. 미전달 시 빈 배열로 처리. } export interface GenerateOptions { diff --git a/src/main/ai/LocalOllamaProvider.ts b/src/main/ai/LocalOllamaProvider.ts index e08d54e..953bb23 100644 --- a/src/main/ai/LocalOllamaProvider.ts +++ b/src/main/ai/LocalOllamaProvider.ts @@ -67,7 +67,7 @@ export class LocalOllamaProvider implements InferenceProvider { const model = useVision ? opts!.visionModel! : this.model; const prompt = useVision ? buildVisionPrompt(input.text, input.todayKst, input.dueDateCandidates.map((c) => c.iso ?? c.matchedToken ?? ''), input.vocab ?? []) - : buildPrompt(input.text, input.todayKst, input.dueDateCandidates, input.vocab ?? []); + : buildPrompt(input.text, input.todayKst, input.dueDateCandidates, input.vocab ?? [], input.notebooks ?? []); // v0.3.13 — vision model 은 cold-start (모델 load + 이미지 encoding) 가 매우 느려 // 120s 기본 timeout 으로 첫 호출 fail 빈번. gemma4:26b (MoE 25B) 같은 대형 vision diff --git a/src/main/ai/prompt.ts b/src/main/ai/prompt.ts index a649ec2..bbe8246 100644 --- a/src/main/ai/prompt.ts +++ b/src/main/ai/prompt.ts @@ -6,7 +6,8 @@ export function buildPrompt( rawText: string, todayKst: string, candidates: ParseResult[] = [], - vocab: string[] = [] + vocab: string[] = [], + notebooks: string[] = [] ): string { const candidateBlock = candidates.length > 0 ? `\nDate candidates extracted by a Korean rule parser (these are HINTS — you decide which is correct, or pick null): @@ -17,11 +18,19 @@ ${candidates.map((c, i) => ` ${i + 1}. ${c.iso ?? '(ambiguous)'} — matched to ? `\nExisting vocabulary tags (most-used first): ${vocab.join(', ')}\nPrefer reusing a vocabulary tag when the meaning matches; create new tags only when the meaning is genuinely new.\n` : ''; - // candidateBlock & vocabBlock are self-delimited with leading/trailing \n + const notebookBlock = notebooks.length > 0 + ? `\n사용 가능한 노트북: ${notebooks.join(', ')}\n이 노트가 위 노트북 중 하나에 명확히 속하면 "notebook_match" 에 그 이름을, 그렇지 않으면 null 을 반환. 기존 목록 안에서만 선택 — 새 이름 만들지 말 것.\n` + : ''; + + const notebookKeySpec = notebooks.length > 0 + ? `\n- "notebook_match": 위 사용 가능한 노트북 목록 중 하나의 이름 또는 null` + : ''; + + // candidateBlock & vocabBlock & notebookBlock are self-delimited with leading/trailing \n return `You organize raw personal notes into structured metadata. Today's date in Korea Standard Time (KST): ${todayKst} -${candidateBlock}${vocabBlock} +${candidateBlock}${vocabBlock}${notebookBlock} Input note (raw text, may be fragmented, any language): --- ${rawText} @@ -31,7 +40,7 @@ Return a JSON object with EXACTLY these keys: - "title": concise title in KOREAN (max 60 chars) - "summary": 3-line summary in KOREAN. Each line max 120 chars. Lines separated by "\\n". - "tags": array of 0 to 3 tags in lowercase kebab-case (English letters and digits only, e.g., "api-timeout", "weekly-retro"). Empty array if no clear tags. -- "due_date": ISO YYYY-MM-DD if you are CONFIDENT about a deadline, else null. Consider rule candidates above as hints but use your own judgment — if multiple ambiguous candidates ("내일 모레", "이번 주 다음 주"), prefer null. If the user wrote "오늘 PR 리뷰" with no deadline implication, return null. +- "due_date": ISO YYYY-MM-DD if you are CONFIDENT about a deadline, else null. Consider rule candidates above as hints but use your own judgment — if multiple ambiguous candidates ("내일 모레", "이번 주 다음 주"), prefer null. If the user wrote "오늘 PR 리뷰" with no deadline implication, return null.${notebookKeySpec} Rules: - title and summary MUST be written in Korean regardless of input language. diff --git a/src/main/ai/schema.ts b/src/main/ai/schema.ts index 09ebd1b..9c1f236 100644 --- a/src/main/ai/schema.ts +++ b/src/main/ai/schema.ts @@ -8,7 +8,8 @@ const RawResponseSchema = z.object({ title: z.string().trim().min(1).max(200), summary: z.string().min(1), tags: z.array(z.string()).default([]), - due_date: z.string().regex(ISO_DATE_REGEX).nullable().optional() + due_date: z.string().regex(ISO_DATE_REGEX).nullable().optional(), + notebook_match: z.string().nullable().optional() }); export interface AiResponse { @@ -16,6 +17,7 @@ export interface AiResponse { summary: string; tags: string[]; dueDate: string | null; + notebookMatch: string | null; } function normalizeSummary(raw: string): string { @@ -69,6 +71,7 @@ export function parseAiResponse(raw: unknown): AiResponse { title: finalTitle.slice(0, 60), summary: normalizeSummary(parsed.summary), tags: parsed.tags.filter((t) => KEBAB_REGEX.test(t)).slice(0, 3), - dueDate: validateDueDate(parsed.due_date) + dueDate: validateDueDate(parsed.due_date), + notebookMatch: parsed.notebook_match ?? null }; } diff --git a/tests/unit/AiWorker.test.ts b/tests/unit/AiWorker.test.ts index 93ec987..c8043d1 100644 --- a/tests/unit/AiWorker.test.ts +++ b/tests/unit/AiWorker.test.ts @@ -14,7 +14,7 @@ function makeProvider(overrides: Partial = {}): InferenceProv return { name: 'mock', generate: vi.fn(async (): Promise => ({ - 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 => { 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[] = []; diff --git a/tests/unit/AiWorker.vision.test.ts b/tests/unit/AiWorker.vision.test.ts index d13502d..378269f 100644 --- a/tests/unit/AiWorker.vision.test.ts +++ b/tests/unit/AiWorker.vision.test.ts @@ -65,7 +65,7 @@ describe('AiWorker — vision path (v0.3.1 Cut F)', () => { opts?: Parameters[1] ): Promise => { 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 => 'gemma3:12b-vision'); const worker = makeWorker(generate, getVisionModel); @@ -87,7 +87,7 @@ describe('AiWorker — vision path (v0.3.1 Cut F)', () => { opts?: Parameters[1] ): Promise => { 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 => null); const worker = makeWorker(generate, getVisionModel); @@ -110,7 +110,7 @@ describe('AiWorker — vision path (v0.3.1 Cut F)', () => { opts?: Parameters[1] ): Promise => { 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 => '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 => ({ title: 't', summary: 'a\nb\nc', tags: [], dueDate: null })); + const generate = vi.fn(async (): Promise => ({ title: 't', summary: 'a\nb\nc', tags: [], dueDate: null, notebookMatch: null })); const getVisionModel = vi.fn(async (): Promise => '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[1] ): Promise => { 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 => 'gemma3:12b-vision'); const worker = makeWorker(generate, getVisionModel); diff --git a/tests/unit/ai-schema.test.ts b/tests/unit/ai-schema.test.ts index a332724..0fa7782 100644 --- a/tests/unit/ai-schema.test.ts +++ b/tests/unit/ai-schema.test.ts @@ -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(); + }); }); diff --git a/tests/unit/prompt.test.ts b/tests/unit/prompt.test.ts index 6d27932..32334e5 100644 --- a/tests/unit/prompt.test.ts +++ b/tests/unit/prompt.test.ts @@ -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('사용 가능한 노트북'); + }); +});