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:
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user