fix(schema): null/empty title/summary 를 placeholder 로 coerce
gemma4:26b 가 본문 빈 케이스에 title=null/summary=null 반환 → schema throw. prompt 강화로 부족. schema 단계에서 graceful coerce: - null/empty title → '(첨부 메모)' - null/empty summary → '내용을 자동으로 정리하지 못했습니다.' - 영어 title → '(첨부 메모)' (이전엔 throw) - malformed/empty due_date → null (이전엔 throw) raw_text 는 호출자가 보존하므로 사용자 데이터 손실 없음. 사용자가 후에 NoteCard 에서 직접 편집 가능. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -39,13 +39,34 @@ function validateDueDate(d: string | null | undefined): string | null {
|
||||
return d;
|
||||
}
|
||||
|
||||
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');
|
||||
/**
|
||||
* 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 === '') obj.title = '(첨부 메모)';
|
||||
if (obj.summary === null || 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: parsed.title.slice(0, 60),
|
||||
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)
|
||||
|
||||
@@ -13,10 +13,21 @@ describe('parseAiResponse', () => {
|
||||
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('영어 title → (첨부 메모) placeholder fallback (vision graceful 처리)', () => {
|
||||
const r = parseAiResponse({ title: 'English only', summary: 'a\nb\nc', tags: [] });
|
||||
expect(r.title).toBe('(첨부 메모)');
|
||||
});
|
||||
|
||||
it('null title/summary → placeholder coerce (vision 본문 빈 케이스)', () => {
|
||||
const r = parseAiResponse({ title: null, summary: null, tags: [], due_date: null });
|
||||
expect(r.title).toBe('(첨부 메모)');
|
||||
expect(r.summary.startsWith('내용을 자동으로 정리하지 못했습니다')).toBe(true);
|
||||
});
|
||||
|
||||
it('empty string title/summary → placeholder coerce', () => {
|
||||
const r = parseAiResponse({ title: '', summary: '', tags: [] });
|
||||
expect(r.title).toBe('(첨부 메모)');
|
||||
expect(r.summary.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('pads short summary to 3 lines', () => {
|
||||
@@ -82,10 +93,14 @@ describe('parseAiResponse', () => {
|
||||
expect(r.dueDate).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects malformed due_date string', () => {
|
||||
expect(() =>
|
||||
parseAiResponse({ title: '내일', summary: 'a\nb\nc', tags: [], due_date: 'tomorrow' })
|
||||
).toThrow();
|
||||
it('malformed due_date string → null coerce (vision graceful 처리)', () => {
|
||||
const r = parseAiResponse({ title: '내일', summary: 'a\nb\nc', tags: [], due_date: 'tomorrow' });
|
||||
expect(r.dueDate).toBeNull();
|
||||
});
|
||||
|
||||
it('empty string due_date → null coerce', () => {
|
||||
const r = parseAiResponse({ title: '내일', summary: 'a\nb\nc', tags: [], due_date: '' });
|
||||
expect(r.dueDate).toBeNull();
|
||||
});
|
||||
|
||||
it('coerces invalid date that passes regex (e.g. 2026-13-99) to null', () => {
|
||||
|
||||
Reference in New Issue
Block a user