feat(ai): zod due_date field + prompt {{TODAY_KST}} injection
AiResponse extends with dueDate: string|null. zod regex
^\d{4}-\d{2}-\d{2}$, follow-up roundtrip check coerces invalid
dates (2026-13-99 etc.) to null. PROMPT_VERSION → 2: prompt now
takes todayKst arg, asks model to extract due_date as ISO or null.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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.`;
|
||||
|
||||
@@ -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)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user