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

@@ -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 {

View File

@@ -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

View File

@@ -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.

View File

@@ -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
};
}

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('사용 가능한 노트북');
});
});