Files
inkling/src/main/ai/schema.ts
2026-05-15 10:34:11 +09:00

78 lines
3.1 KiB
TypeScript

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([]),
due_date: z.string().regex(ISO_DATE_REGEX).nullable().optional(),
notebook_match: z.string().nullable().optional()
});
export interface AiResponse {
title: string;
summary: string;
tags: string[];
dueDate: string | null;
notebookMatch: string | null;
}
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');
}
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;
}
/**
* vision 모델 (gemma4:26b 등) 이 본문 빈 케이스에 title/summary null 반환하는 케이스
* 대응. null → placeholder 한국어 문자열로 coerce 후 schema 통과. 빈 string / empty regex
* dueDate 도 null 로 normalize. raw_text 는 호출자가 보존하므로 사용자 데이터 손실 없음.
*/
function coerceNullable(raw: unknown): unknown {
if (typeof raw !== 'object' || raw === null) return raw;
const obj = { ...(raw as Record<string, unknown>) };
if (obj.title === null || obj.title === undefined || obj.title === '') obj.title = '(첨부 메모)';
if (obj.summary === null || obj.summary === undefined || obj.summary === '') obj.summary = '내용을 자동으로 정리하지 못했습니다.';
// due_date 의 빈 string / regex mismatch 도 null 로 강제 (schema 가 거부하지 않게).
if (obj.due_date === '' || (typeof obj.due_date === 'string' && !ISO_DATE_REGEX.test(obj.due_date))) {
obj.due_date = null;
}
// tags 가 null 이면 빈 배열로.
if (obj.tags === null || obj.tags === undefined) obj.tags = [];
return obj;
}
export function parseAiResponse(raw: unknown): AiResponse {
const coerced = coerceNullable(raw);
const parsed = RawResponseSchema.parse(coerced);
// title 이 한국어 0 자면 fallback placeholder 적용 (영어 title 도 fail 안 함).
// placeholder 는 한국어 포함이라 자기 자신 통과.
const titleHasKorean = KOREAN_REGEX.test(parsed.title);
const finalTitle = titleHasKorean ? parsed.title : '(첨부 메모)';
return {
title: finalTitle.slice(0, 60),
summary: normalizeSummary(parsed.summary),
tags: parsed.tags.filter((t) => KEBAB_REGEX.test(t)).slice(0, 3),
dueDate: validateDueDate(parsed.due_date),
notebookMatch: parsed.notebook_match ?? null
};
}