78 lines
3.1 KiB
TypeScript
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
|
|
};
|
|
}
|