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 태그. 미전달 시 빈 배열로 처리.
|
vocab?: string[]; // v0.2.3 #3 — top-N kebab-case 태그. 미전달 시 빈 배열로 처리.
|
||||||
// v0.3.1 Cut F — 첨부 이미지. 미전달 시 텍스트 전용 처리.
|
// v0.3.1 Cut F — 첨부 이미지. 미전달 시 텍스트 전용 처리.
|
||||||
images?: Array<{ base64: string; mime: string }>;
|
images?: Array<{ base64: string; mime: string }>;
|
||||||
|
notebooks?: string[]; // v0.4 Task 8 — 사용 가능한 노트북 목록. 미전달 시 빈 배열로 처리.
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GenerateOptions {
|
export interface GenerateOptions {
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ export class LocalOllamaProvider implements InferenceProvider {
|
|||||||
const model = useVision ? opts!.visionModel! : this.model;
|
const model = useVision ? opts!.visionModel! : this.model;
|
||||||
const prompt = useVision
|
const prompt = useVision
|
||||||
? buildVisionPrompt(input.text, input.todayKst, input.dueDateCandidates.map((c) => c.iso ?? c.matchedToken ?? ''), input.vocab ?? [])
|
? 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) 가 매우 느려
|
// v0.3.13 — vision model 은 cold-start (모델 load + 이미지 encoding) 가 매우 느려
|
||||||
// 120s 기본 timeout 으로 첫 호출 fail 빈번. gemma4:26b (MoE 25B) 같은 대형 vision
|
// 120s 기본 timeout 으로 첫 호출 fail 빈번. gemma4:26b (MoE 25B) 같은 대형 vision
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ export function buildPrompt(
|
|||||||
rawText: string,
|
rawText: string,
|
||||||
todayKst: string,
|
todayKst: string,
|
||||||
candidates: ParseResult[] = [],
|
candidates: ParseResult[] = [],
|
||||||
vocab: string[] = []
|
vocab: string[] = [],
|
||||||
|
notebooks: string[] = []
|
||||||
): string {
|
): string {
|
||||||
const candidateBlock = candidates.length > 0
|
const candidateBlock = candidates.length > 0
|
||||||
? `\nDate candidates extracted by a Korean rule parser (these are HINTS — you decide which is correct, or pick null):
|
? `\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`
|
? `\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.
|
return `You organize raw personal notes into structured metadata.
|
||||||
|
|
||||||
Today's date in Korea Standard Time (KST): ${todayKst}
|
Today's date in Korea Standard Time (KST): ${todayKst}
|
||||||
${candidateBlock}${vocabBlock}
|
${candidateBlock}${vocabBlock}${notebookBlock}
|
||||||
Input note (raw text, may be fragmented, any language):
|
Input note (raw text, may be fragmented, any language):
|
||||||
---
|
---
|
||||||
${rawText}
|
${rawText}
|
||||||
@@ -31,7 +40,7 @@ Return a JSON object with EXACTLY these keys:
|
|||||||
- "title": concise title in KOREAN (max 60 chars)
|
- "title": concise title in KOREAN (max 60 chars)
|
||||||
- "summary": 3-line summary in KOREAN. Each line max 120 chars. Lines separated by "\\n".
|
- "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.
|
- "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:
|
Rules:
|
||||||
- title and summary MUST be written in Korean regardless of input language.
|
- 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),
|
title: z.string().trim().min(1).max(200),
|
||||||
summary: z.string().min(1),
|
summary: z.string().min(1),
|
||||||
tags: z.array(z.string()).default([]),
|
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 {
|
export interface AiResponse {
|
||||||
@@ -16,6 +17,7 @@ export interface AiResponse {
|
|||||||
summary: string;
|
summary: string;
|
||||||
tags: string[];
|
tags: string[];
|
||||||
dueDate: string | null;
|
dueDate: string | null;
|
||||||
|
notebookMatch: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeSummary(raw: string): string {
|
function normalizeSummary(raw: string): string {
|
||||||
@@ -69,6 +71,7 @@ export function parseAiResponse(raw: unknown): AiResponse {
|
|||||||
title: finalTitle.slice(0, 60),
|
title: finalTitle.slice(0, 60),
|
||||||
summary: normalizeSummary(parsed.summary),
|
summary: normalizeSummary(parsed.summary),
|
||||||
tags: parsed.tags.filter((t) => KEBAB_REGEX.test(t)).slice(0, 3),
|
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 {
|
return {
|
||||||
name: 'mock',
|
name: 'mock',
|
||||||
generate: vi.fn(async (): Promise<AiResponse> => ({
|
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 })),
|
healthCheck: vi.fn(async () => ({ ok: true })),
|
||||||
...overrides
|
...overrides
|
||||||
@@ -77,7 +77,7 @@ describe('AiWorker', () => {
|
|||||||
running++; max = Math.max(max, running);
|
running++; max = Math.max(max, running);
|
||||||
await new Promise((r) => setTimeout(r, 10));
|
await new Promise((r) => setTimeout(r, 10));
|
||||||
running--;
|
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] });
|
const w = new AiWorker(repo, new ProviderHolder(provider), { backoffsMs: [0, 0, 0] });
|
||||||
@@ -137,7 +137,8 @@ describe('AiWorker', () => {
|
|||||||
title: '아무 메모',
|
title: '아무 메모',
|
||||||
summary: 'a\nb\nc',
|
summary: 'a\nb\nc',
|
||||||
tags: [],
|
tags: [],
|
||||||
dueDate: null
|
dueDate: null,
|
||||||
|
notebookMatch: null
|
||||||
}),
|
}),
|
||||||
healthCheck: async () => ({ ok: true })
|
healthCheck: async () => ({ ok: true })
|
||||||
} as any;
|
} as any;
|
||||||
@@ -159,7 +160,7 @@ describe('AiWorker', () => {
|
|||||||
generate: async (input: any) => {
|
generate: async (input: any) => {
|
||||||
seen.todayKst = input.todayKst;
|
seen.todayKst = input.todayKst;
|
||||||
seen.dueDateCandidates = input.dueDateCandidates;
|
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 })
|
healthCheck: async () => ({ ok: true })
|
||||||
} as any;
|
} as any;
|
||||||
@@ -181,7 +182,7 @@ describe('AiWorker', () => {
|
|||||||
name: 'mock',
|
name: 'mock',
|
||||||
generate: async (input: any) => {
|
generate: async (input: any) => {
|
||||||
captured = input;
|
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 })
|
healthCheck: async () => ({ ok: true })
|
||||||
} as any;
|
} as any;
|
||||||
@@ -409,7 +410,7 @@ describe('AiWorker — unreachable/timeout infinite retry (v0.2.3 #2)', () => {
|
|||||||
generate: vi.fn(async (): Promise<AiResponse> => {
|
generate: vi.fn(async (): Promise<AiResponse> => {
|
||||||
callCount += 1;
|
callCount += 1;
|
||||||
if (callCount <= 2) throw new Error('ECONNREFUSED');
|
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), {
|
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 { id } = repo.create({ rawText: 'x' });
|
||||||
const generateMock = vi.fn(async () => ({
|
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 })), {
|
const w = new AiWorker(repo, new ProviderHolder(makeProvider({ generate: generateMock })), {
|
||||||
backoffsMs: [0, 0, 0]
|
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 () => ({
|
generate: vi.fn(async () => ({
|
||||||
title: 't', summary: 'a\nb\nc',
|
title: 't', summary: 'a\nb\nc',
|
||||||
tags: ['design', 'newtag'], // 1 hit + 1 miss
|
tags: ['design', 'newtag'], // 1 hit + 1 miss
|
||||||
dueDate: null
|
dueDate: null,
|
||||||
|
notebookMatch: null
|
||||||
}))
|
}))
|
||||||
});
|
});
|
||||||
const emits: EmittedEvent[] = [];
|
const emits: EmittedEvent[] = [];
|
||||||
@@ -495,7 +497,8 @@ describe('AiWorker — vocab fetch + per-tag hit/miss (v0.2.3 #3 T7)', () => {
|
|||||||
generate: vi.fn(async () => ({
|
generate: vi.fn(async () => ({
|
||||||
title: 't', summary: 'a\nb\nc',
|
title: 't', summary: 'a\nb\nc',
|
||||||
tags: ['design', 'meeting', 'qa'],
|
tags: ['design', 'meeting', 'qa'],
|
||||||
dueDate: null
|
dueDate: null,
|
||||||
|
notebookMatch: null
|
||||||
}))
|
}))
|
||||||
});
|
});
|
||||||
const emits: EmittedEvent[] = [];
|
const emits: EmittedEvent[] = [];
|
||||||
@@ -520,7 +523,8 @@ describe('AiWorker — vocab fetch + per-tag hit/miss (v0.2.3 #3 T7)', () => {
|
|||||||
generate: vi.fn(async () => ({
|
generate: vi.fn(async () => ({
|
||||||
title: 't', summary: 'a\nb\nc',
|
title: 't', summary: 'a\nb\nc',
|
||||||
tags: ['design', 'meeting', 'qa'],
|
tags: ['design', 'meeting', 'qa'],
|
||||||
dueDate: null
|
dueDate: null,
|
||||||
|
notebookMatch: null
|
||||||
}))
|
}))
|
||||||
});
|
});
|
||||||
const emits: EmittedEvent[] = [];
|
const emits: EmittedEvent[] = [];
|
||||||
@@ -544,7 +548,8 @@ describe('AiWorker — vocab fetch + per-tag hit/miss (v0.2.3 #3 T7)', () => {
|
|||||||
generate: vi.fn(async () => ({
|
generate: vi.fn(async () => ({
|
||||||
title: 't', summary: 'a\nb\nc',
|
title: 't', summary: 'a\nb\nc',
|
||||||
tags: ['design', 'design', 'meeting'], // 중복 'design' 의도적
|
tags: ['design', 'design', 'meeting'], // 중복 'design' 의도적
|
||||||
dueDate: null
|
dueDate: null,
|
||||||
|
notebookMatch: null
|
||||||
}))
|
}))
|
||||||
});
|
});
|
||||||
const emits: EmittedEvent[] = [];
|
const emits: EmittedEvent[] = [];
|
||||||
@@ -581,7 +586,8 @@ describe('vocab COLLATE NOCASE', () => {
|
|||||||
generate: vi.fn(async () => ({
|
generate: vi.fn(async () => ({
|
||||||
title: 't', summary: 'a\nb\nc',
|
title: 't', summary: 'a\nb\nc',
|
||||||
tags: ['Design'], // AI returns capitalized — DB COLLATE NOCASE matches 'design'
|
tags: ['Design'], // AI returns capitalized — DB COLLATE NOCASE matches 'design'
|
||||||
dueDate: null
|
dueDate: null,
|
||||||
|
notebookMatch: null
|
||||||
}))
|
}))
|
||||||
});
|
});
|
||||||
const emits: EmittedEvent[] = [];
|
const emits: EmittedEvent[] = [];
|
||||||
@@ -610,7 +616,8 @@ describe('vocab COLLATE NOCASE', () => {
|
|||||||
generate: vi.fn(async () => ({
|
generate: vi.fn(async () => ({
|
||||||
title: 't', summary: 'a\nb\nc',
|
title: 't', summary: 'a\nb\nc',
|
||||||
tags: ['design'], // AI returns lowercase — DB COLLATE NOCASE matches 'Design'
|
tags: ['design'], // AI returns lowercase — DB COLLATE NOCASE matches 'Design'
|
||||||
dueDate: null
|
dueDate: null,
|
||||||
|
notebookMatch: null
|
||||||
}))
|
}))
|
||||||
});
|
});
|
||||||
const emits: EmittedEvent[] = [];
|
const emits: EmittedEvent[] = [];
|
||||||
@@ -634,7 +641,8 @@ describe('vocab COLLATE NOCASE', () => {
|
|||||||
generate: vi.fn(async () => ({
|
generate: vi.fn(async () => ({
|
||||||
title: 't', summary: 'a\nb\nc',
|
title: 't', summary: 'a\nb\nc',
|
||||||
tags: ['design'], // same lowercase — should still hit
|
tags: ['design'], // same lowercase — should still hit
|
||||||
dueDate: null
|
dueDate: null,
|
||||||
|
notebookMatch: null
|
||||||
}))
|
}))
|
||||||
});
|
});
|
||||||
const emits: EmittedEvent[] = [];
|
const emits: EmittedEvent[] = [];
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ describe('AiWorker — vision path (v0.3.1 Cut F)', () => {
|
|||||||
opts?: Parameters<InferenceProvider['generate']>[1]
|
opts?: Parameters<InferenceProvider['generate']>[1]
|
||||||
): Promise<AiResponse> => {
|
): Promise<AiResponse> => {
|
||||||
calls.push([input, opts]);
|
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 getVisionModel = vi.fn(async (): Promise<string | null> => 'gemma3:12b-vision');
|
||||||
const worker = makeWorker(generate, getVisionModel);
|
const worker = makeWorker(generate, getVisionModel);
|
||||||
@@ -87,7 +87,7 @@ describe('AiWorker — vision path (v0.3.1 Cut F)', () => {
|
|||||||
opts?: Parameters<InferenceProvider['generate']>[1]
|
opts?: Parameters<InferenceProvider['generate']>[1]
|
||||||
): Promise<AiResponse> => {
|
): Promise<AiResponse> => {
|
||||||
calls.push([input, opts]);
|
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 getVisionModel = vi.fn(async (): Promise<string | null> => null);
|
||||||
const worker = makeWorker(generate, getVisionModel);
|
const worker = makeWorker(generate, getVisionModel);
|
||||||
@@ -110,7 +110,7 @@ describe('AiWorker — vision path (v0.3.1 Cut F)', () => {
|
|||||||
opts?: Parameters<InferenceProvider['generate']>[1]
|
opts?: Parameters<InferenceProvider['generate']>[1]
|
||||||
): Promise<AiResponse> => {
|
): Promise<AiResponse> => {
|
||||||
calls.push([input, opts]);
|
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 getVisionModel = vi.fn(async (): Promise<string | null> => 'gemma4:26b');
|
||||||
const worker = makeWorker(generate, getVisionModel);
|
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}/1.png`, mime: 'image/png', bytes: 1 },
|
||||||
{ noteId: id, kind: 'image', relPath: `media/${id}/2.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 getVisionModel = vi.fn(async (): Promise<string | null> => 'gemma4:26b');
|
||||||
const worker = makeWorker(generate, getVisionModel);
|
const worker = makeWorker(generate, getVisionModel);
|
||||||
await worker.enqueue(id);
|
await worker.enqueue(id);
|
||||||
@@ -157,7 +157,7 @@ describe('AiWorker — vision path (v0.3.1 Cut F)', () => {
|
|||||||
opts?: Parameters<InferenceProvider['generate']>[1]
|
opts?: Parameters<InferenceProvider['generate']>[1]
|
||||||
): Promise<AiResponse> => {
|
): Promise<AiResponse> => {
|
||||||
calls.push([input, opts]);
|
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 getVisionModel = vi.fn(async (): Promise<string | null> => 'gemma3:12b-vision');
|
||||||
const worker = makeWorker(generate, getVisionModel);
|
const worker = makeWorker(generate, getVisionModel);
|
||||||
|
|||||||
@@ -112,4 +112,19 @@ describe('parseAiResponse', () => {
|
|||||||
});
|
});
|
||||||
expect(r.dueDate).toBeNull();
|
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);
|
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