feat(ai): zod schema validator + Korean-first prompt
Task 10 of the slice plan. parseAiResponse runs the model JSON through a zod RawResponseSchema, then enforces slice rules: - title MUST contain Korean characters; throw otherwise. - summary normalized to exactly 3 lines (pad with empty when too few; collapse trailing lines into line 3 when too many). - tags filtered to /^[a-z0-9]+(-[a-z0-9]+)*$/ kebab-case and capped at 3. buildPrompt assembles the user-facing prompt with explicit Korean-output / kebab-tag / no-fence rules (PROMPT_VERSION=1). Verification: `npx vitest run tests/unit/ai-schema.test.ts` 7 passed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
22
src/main/ai/prompt.ts
Normal file
22
src/main/ai/prompt.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
export const PROMPT_VERSION = 1;
|
||||
|
||||
export function buildPrompt(rawText: string): string {
|
||||
return `You organize raw personal notes into structured metadata.
|
||||
|
||||
Input note (raw text, may be fragmented, any language):
|
||||
---
|
||||
${rawText}
|
||||
---
|
||||
|
||||
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.
|
||||
|
||||
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).
|
||||
- Do NOT invent facts not present in the input.
|
||||
- Do NOT include markdown code fences or preamble.
|
||||
- Return ONLY the JSON object.`;
|
||||
}
|
||||
41
src/main/ai/schema.ts
Normal file
41
src/main/ai/schema.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
const KOREAN_REGEX = /[가-힣]/;
|
||||
const KEBAB_REGEX = /^[a-z0-9]+(-[a-z0-9]+)*$/;
|
||||
|
||||
const RawResponseSchema = z.object({
|
||||
title: z.string().trim().min(1).max(200),
|
||||
summary: z.string().min(1),
|
||||
tags: z.array(z.string()).default([])
|
||||
});
|
||||
|
||||
export interface AiResponse {
|
||||
title: string;
|
||||
summary: string;
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
function normalizeSummary(raw: string): string {
|
||||
const lines = raw.split(/\r?\n/).map((l) => l.trim()).filter((l) => l.length > 0);
|
||||
if (lines.length === 0) throw new Error('summary is empty');
|
||||
if (lines.length === 3) return lines.join('\n');
|
||||
if (lines.length < 3) {
|
||||
while (lines.length < 3) lines.push('');
|
||||
return lines.join('\n');
|
||||
}
|
||||
const head = lines.slice(0, 2);
|
||||
const tail = lines.slice(2).join(' ');
|
||||
return [...head, tail].join('\n');
|
||||
}
|
||||
|
||||
export function parseAiResponse(raw: unknown): AiResponse {
|
||||
const parsed = RawResponseSchema.parse(raw);
|
||||
if (!KOREAN_REGEX.test(parsed.title)) {
|
||||
throw new Error('title must contain Korean characters');
|
||||
}
|
||||
return {
|
||||
title: parsed.title.slice(0, 60),
|
||||
summary: normalizeSummary(parsed.summary),
|
||||
tags: parsed.tags.filter((t) => KEBAB_REGEX.test(t)).slice(0, 3)
|
||||
};
|
||||
}
|
||||
55
tests/unit/ai-schema.test.ts
Normal file
55
tests/unit/ai-schema.test.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parseAiResponse } from '@main/ai/schema.js';
|
||||
|
||||
describe('parseAiResponse', () => {
|
||||
it('accepts valid Korean title, 3-line summary, kebab tags', () => {
|
||||
const r = parseAiResponse({
|
||||
title: '회의 요약',
|
||||
summary: '첫 줄\n둘째 줄\n셋째 줄',
|
||||
tags: ['api-timeout', 'meeting']
|
||||
});
|
||||
expect(r.title).toBe('회의 요약');
|
||||
expect(r.summary.split('\n')).toHaveLength(3);
|
||||
expect(r.tags).toEqual(['api-timeout', 'meeting']);
|
||||
});
|
||||
|
||||
it('rejects title without Korean', () => {
|
||||
expect(() =>
|
||||
parseAiResponse({ title: 'English only', summary: 'a\nb\nc', tags: [] })
|
||||
).toThrow(/korean/i);
|
||||
});
|
||||
|
||||
it('pads short summary to 3 lines', () => {
|
||||
const r = parseAiResponse({ title: '제목', summary: '한 줄', tags: [] });
|
||||
expect(r.summary.split('\n')).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('compresses long summary to 3 lines', () => {
|
||||
const r = parseAiResponse({
|
||||
title: '제목', summary: 'a\nb\nc\nd\ne', tags: []
|
||||
});
|
||||
const lines = r.summary.split('\n');
|
||||
expect(lines).toHaveLength(3);
|
||||
expect(lines[2]).toBe('c d e');
|
||||
});
|
||||
|
||||
it('filters invalid tags', () => {
|
||||
const r = parseAiResponse({
|
||||
title: '제목', summary: 'a\nb\nc',
|
||||
tags: ['good-tag', 'BadCase', 'has space', 'ok2', '']
|
||||
});
|
||||
expect(r.tags).toEqual(['good-tag', 'ok2']);
|
||||
});
|
||||
|
||||
it('caps tags to 3', () => {
|
||||
const r = parseAiResponse({
|
||||
title: '제목', summary: 'a\nb\nc',
|
||||
tags: ['a', 'b', 'c', 'd', 'e']
|
||||
});
|
||||
expect(r.tags).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('rejects non-object input', () => {
|
||||
expect(() => parseAiResponse('nope')).toThrow();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user