diff --git a/backend/src/modules/ai/ai.service.ts b/backend/src/modules/ai/ai.service.ts index de4ddc6..54ea142 100644 --- a/backend/src/modules/ai/ai.service.ts +++ b/backend/src/modules/ai/ai.service.ts @@ -84,6 +84,7 @@ const SUGGEST_SYSTEM_PROMPT = [ '', '[규칙]', '- 반드시 아래 JSON 형식으로만 응답하세요. 설명·인사·마크다운 코드펜스 없이 순수 JSON 만 출력합니다.', + '- 출력은 완결된 단일 JSON 객체여야 합니다. 모든 객체는 중괄호 {}로 감싸고, 배열 []·문자열 "" 을 정확히 닫으세요. 중간에 끊지 마세요.', '- 카테고리는 정확히 "필수 기능", "관리 기능", "보안 기능", "부가 기능" 4개를 이 순서대로 사용하세요. (다른 이름·추가/누락 카테고리 금지)', '- 각 카테고리에 필요한 항목을 충분히 제안하세요. 항목 수에 제한은 없습니다. 적합한 항목이 없으면 빈 배열([])로 두세요.', '- 각 항목은 한국어로 간결한 한 문장(체크리스트 항목)으로 작성합니다.', @@ -221,34 +222,46 @@ export class AiService { const raw = await this.callClaude( [{ role: 'user', content: userMsg }], SUGGEST_SYSTEM_PROMPT, - 4096, + 8000, ); return { groups: this.parseSuggestions(raw) }; } // Claude 의 JSON 응답을 파싱·정규화(코드펜스/잡텍스트 방어) private parseSuggestions(raw: string): ChecklistSuggestionGroup[] { - let text = raw + const text = raw .trim() .replace(/^```(?:json)?\s*/i, '') - .replace(/```$/, '') + .replace(/```\s*$/, '') .trim(); + + // 1) 표준 파싱 — 첫 { ~ 마지막 } 구간만 잘라 JSON.parse + let groups: unknown[] = []; const start = text.indexOf('{'); const end = text.lastIndexOf('}'); - if (start >= 0 && end > start) text = text.slice(start, end + 1); - - let parsed: unknown; - try { - parsed = JSON.parse(text); - } catch { - this.logger.warn( - `추천 JSON 파싱 실패(응답 잘림 가능) — 원본 ${raw.length}자: ${raw.slice(0, 300)}`, - ); - throw this.suggestFailed(); + if (start >= 0 && end > start) { + try { + const parsed = JSON.parse(text.slice(start, end + 1)) as { + groups?: unknown; + }; + if (Array.isArray(parsed.groups)) groups = parsed.groups; + } catch { + // 표준 파싱 실패 → 아래 견고 파싱으로 폴백 + } + } + + // 2) 견고 파싱(폴백) — 모델이 깨진/잘린 JSON(누락 중괄호 등)을 내도 + // "category"/"items" 패턴을 정규식으로 직접 복구한다. + if (groups.length === 0) { + const recovered = this.recoverGroups(text); + if (recovered.length > 0) { + groups = recovered; + this.logger.warn( + `추천 JSON 표준 파싱 실패 → 견고 파싱으로 복구(${recovered.length}그룹). 원본 ${raw.length}자`, + ); + } } - const rawGroups = (parsed as { groups?: unknown }).groups; - const groups: unknown[] = Array.isArray(rawGroups) ? rawGroups : []; const result: ChecklistSuggestionGroup[] = []; for (const g of groups.slice(0, 8)) { const obj = g as { category?: unknown; items?: unknown }; @@ -271,6 +284,36 @@ export class AiService { return result; } + // 깨진/잘린 JSON 에서도 카테고리·항목을 정규식으로 복구(중괄호 누락·꼬리 잘림 방어). + // 완결된 "category":"...","items":[ ... ] 블록만 추출하므로, 잘린 마지막 그룹은 버리고 나머지는 살린다. + private recoverGroups(text: string): { category: string; items: string[] }[] { + const out: { category: string; items: string[] }[] = []; + const groupRe = + /"category"\s*:\s*("(?:[^"\\]|\\.)*")\s*,\s*"items"\s*:\s*\[([\s\S]*?)\]/g; + const itemRe = /"(?:[^"\\]|\\.)*"/g; + let gm: RegExpExecArray | null; + while ((gm = groupRe.exec(text)) !== null) { + let category: string; + try { + category = JSON.parse(gm[1]) as string; + } catch { + continue; + } + const items: string[] = []; + let im: RegExpExecArray | null; + while ((im = itemRe.exec(gm[2])) !== null) { + try { + const s = JSON.parse(im[0]) as unknown; + if (typeof s === 'string') items.push(s); + } catch { + // 깨진 토큰은 건너뛴다 + } + } + out.push({ category, items }); + } + return out; + } + private suggestFailed(): BusinessException { return new BusinessException( 'BIZ_001',