fix: AI 체크리스트 추천 응답 견고 파싱으로 502 방지

Claude가 깨진/잘린 JSON을 반환해도 정규식으로 카테고리·항목 복구(폴백), 프롬프트에 완결 JSON 규칙 추가, max_tokens 상향

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-30 18:21:37 +09:00
parent cf1f4db6af
commit 5a92813a31
+58 -15
View File
@@ -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',