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:
altair823
2026-04-26 11:12:45 +09:00
parent 95ba1653d7
commit 4ee135dcd6
3 changed files with 65 additions and 4 deletions

View File

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

View File

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

View File

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