feat(ai): prompt 에 notebooks 목록 + schema 의 notebook_match 필드
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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