diff --git a/src/main/ai/prompt.ts b/src/main/ai/prompt.ts index da41c5d..2cbdf95 100644 --- a/src/main/ai/prompt.ts +++ b/src/main/ai/prompt.ts @@ -1,8 +1,10 @@ -export const PROMPT_VERSION = 1; +export const PROMPT_VERSION = 2; -export function buildPrompt(rawText: string): string { +export function buildPrompt(rawText: string, todayKst: string): string { return `You organize raw personal notes into structured metadata. +Today's date in Korea Standard Time (KST): ${todayKst} + Input note (raw text, may be fragmented, any language): --- ${rawText} @@ -12,10 +14,12 @@ 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 date if you can clearly extract a deadline relative to today (${todayKst} KST), else null. Examples: "월말" → last day of current month; "주말" → upcoming Saturday; "퇴근 전" → today. Be conservative — only return a date if confident. Rules: - title and summary MUST be written in Korean regardless of input language. - tags MUST be English kebab-case (for consistency across notes; easier to search/group). +- due_date MUST be ISO YYYY-MM-DD format or null. Never include time-of-day. - Do NOT invent facts not present in the input. - Do NOT include markdown code fences or preamble. - Return ONLY the JSON object.`; diff --git a/src/main/ai/schema.ts b/src/main/ai/schema.ts index 6350767..4d1b116 100644 --- a/src/main/ai/schema.ts +++ b/src/main/ai/schema.ts @@ -2,17 +2,20 @@ import { z } from 'zod'; const KOREAN_REGEX = /[가-힣]/; const KEBAB_REGEX = /^[a-z0-9]+(-[a-z0-9]+)*$/; +const ISO_DATE_REGEX = /^\d{4}-\d{2}-\d{2}$/; const RawResponseSchema = z.object({ title: z.string().trim().min(1).max(200), 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() }); export interface AiResponse { title: string; summary: string; tags: string[]; + dueDate: string | null; } function normalizeSummary(raw: string): string { @@ -28,6 +31,14 @@ function normalizeSummary(raw: string): string { return [...head, tail].join('\n'); } +function validateDueDate(d: string | null | undefined): string | null { + if (d === null || d === undefined) return null; + // Re-verify the date is actually valid (regex passes 2026-13-99 which is invalid) + const dt = new Date(d + 'T00:00:00Z'); + if (Number.isNaN(dt.getTime()) || dt.toISOString().slice(0, 10) !== d) return null; + return d; +} + export function parseAiResponse(raw: unknown): AiResponse { const parsed = RawResponseSchema.parse(raw); if (!KOREAN_REGEX.test(parsed.title)) { @@ -36,6 +47,7 @@ export function parseAiResponse(raw: unknown): AiResponse { return { title: parsed.title.slice(0, 60), 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) }; } diff --git a/tests/unit/ai-schema.test.ts b/tests/unit/ai-schema.test.ts index c27a4f8..d9acf10 100644 --- a/tests/unit/ai-schema.test.ts +++ b/tests/unit/ai-schema.test.ts @@ -52,4 +52,49 @@ describe('parseAiResponse', () => { it('rejects non-object input', () => { expect(() => parseAiResponse('nope')).toThrow(); }); + + it('parses note with valid due_date', () => { + const r = parseAiResponse({ + title: '내일 회의', + summary: 'a\nb\nc', + tags: [], + due_date: '2026-04-27' + }); + expect(r.dueDate).toBe('2026-04-27'); + }); + + it('null due_date passes through', () => { + const r = parseAiResponse({ + title: '내일 회의', + summary: 'a\nb\nc', + tags: [] + }); + expect(r.dueDate).toBeNull(); + }); + + it('explicit null due_date passes through', () => { + const r = parseAiResponse({ + title: '내일 회의', + summary: 'a\nb\nc', + tags: [], + due_date: null + }); + expect(r.dueDate).toBeNull(); + }); + + it('rejects malformed due_date string', () => { + expect(() => + parseAiResponse({ title: '내일', summary: 'a\nb\nc', tags: [], due_date: 'tomorrow' }) + ).toThrow(); + }); + + it('coerces invalid date that passes regex (e.g. 2026-13-99) to null', () => { + const r = parseAiResponse({ + title: '내일 회의', + summary: 'a\nb\nc', + tags: [], + due_date: '2026-13-99' + }); + expect(r.dueDate).toBeNull(); + }); });