Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion config
11 changes: 4 additions & 7 deletions src/main/java/starlight/adapter/ai/OpenAiChecklistGrader.java
Original file line number Diff line number Diff line change
Expand Up @@ -22,17 +22,14 @@ public class OpenAiChecklistGrader implements ChecklistGrader {
@Override
public List<Boolean> check(
SubSectionType subSectionType,
String newContent,
String previousContent,
List<Boolean> previousChecks
String content
) {
String tag = subSectionType.getTag();

// 1) 서브섹션별 체크리스트 기준 5개 확보
List<String> criteria = checklistCatalog.getCriteriaByTag(tag);
List<String> criteria = checklistCatalog.getCriteriaBySubSectionType(subSectionType);
List<String> detailedCriteria = checklistCatalog.getDetailedCriteriaBySubSectionType(subSectionType);

// 2) LLM 호출 → Boolean 배열 파싱
List<Boolean> result = generator.generateChecklistArray(newContent, criteria, previousContent, previousChecks);
List<Boolean> result = generator.generateChecklistArray(subSectionType, content, criteria, detailedCriteria);

// 3) 보정: 항상 길이 5 보장
return normalizeToFive(result);
Expand Down
22 changes: 17 additions & 5 deletions src/main/java/starlight/adapter/ai/infra/OpenAiGenerator.java
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,11 @@
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.client.advisor.SimpleLoggerAdvisor;
import org.springframework.ai.chat.client.advisor.vectorstore.QuestionAnswerAdvisor;
import org.springframework.ai.chat.prompt.ChatOptions;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.stereotype.Component;
import starlight.application.infrastructure.provided.LlmGenerator;
import starlight.domain.businessplan.enumerate.SubSectionType;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.core.type.TypeReference;

Expand All @@ -25,20 +27,26 @@ public class OpenAiGenerator implements LlmGenerator {

@Override
public List<Boolean> generateChecklistArray(
String newContent,
SubSectionType subSectionType,
String content,
List<String> criteria,
String previousContent,
List<Boolean> previousChecks
List<String> detailedCriteria
) {
Prompt prompt = promptProvider.createChecklistGradingPrompt(
newContent, criteria, previousContent, previousChecks
subSectionType, content, criteria, detailedCriteria
);


ChatClient chatClient = chatClientBuilder.build();

SimpleLoggerAdvisor slAdvisor = advisorProvider.getSimpleLoggerAdvisor();

String output = chatClient
.prompt(prompt)
.options(ChatOptions.builder()
.temperature(0.1)
.topP(0.1)
.build())
.advisors(slAdvisor)
.call()
.content();

Expand All @@ -61,6 +69,10 @@ public String generateReport(String content) {

return chatClient
.prompt(prompt)
.options(ChatOptions.builder()
.temperature(0.1)
.topP(0.1)
.build())
.advisors(qaAdvisor, slAdvisor)
.call()
.content();
Expand Down
237 changes: 43 additions & 194 deletions src/main/java/starlight/adapter/ai/infra/PromptProvider.java

Large diffs are not rendered by default.

155 changes: 139 additions & 16 deletions src/main/java/starlight/adapter/ai/util/AiReportResponseParser.java
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
import org.springframework.stereotype.Component;
import starlight.application.aireport.provided.dto.AiReportResponse;
import starlight.domain.aireport.entity.AiReport;
import starlight.domain.aireport.exception.AiReportException;
import starlight.domain.aireport.exception.AiReportErrorType;

import java.util.ArrayList;
import java.util.List;
Expand Down Expand Up @@ -107,16 +109,122 @@ public AiReportResponse toResponse(AiReport aiReport) {
);
}

/**
* 응답이 기본값(파싱 실패 시 반환되는 값)인지 확인
*/
private boolean isDefaultResponse(AiReportResponse response) {
return (response.problemRecognitionScore() == null || response.problemRecognitionScore() == 0) &&
(response.feasibilityScore() == null || response.feasibilityScore() == 0) &&
(response.growthStrategyScore() == null || response.growthStrategyScore() == 0) &&
(response.teamCompetenceScore() == null || response.teamCompetenceScore() == 0) &&
(response.strengths() == null || response.strengths().isEmpty()) &&
(response.weaknesses() == null || response.weaknesses().isEmpty()) &&
(response.sectionScores() == null || response.sectionScores().isEmpty());
}

/**
* LLM 응답 문자열을 AiReportResponse로 파싱
* 파싱 실패 시 예외를 던집니다.
*/
public AiReportResponse parse(String llmResponse) {
log.debug("Raw LLM response: {}", llmResponse);

// 1. 기본 검증
if (llmResponse == null || llmResponse.trim().isEmpty()) {
log.error("LLM response is null or empty");
throw new AiReportException(AiReportErrorType.AI_RESPONSE_PARSING_FAILED);
}

try {
JsonNode jsonNode = objectMapper.readTree(llmResponse);
return parseFromJsonNode(jsonNode);
// 2. JSON 문자열 정리
String cleanedJson = cleanJsonResponse(llmResponse);
log.debug("Cleaned JSON: {}", cleanedJson);

// 3. JSON 파싱 시도
JsonNode jsonNode = objectMapper.readTree(cleanedJson);

// 4. 필수 필드 존재 여부 확인
if (!jsonNode.has("problemRecognitionScore") ||
!jsonNode.has("feasibilityScore") ||
!jsonNode.has("growthStrategyScore") ||
!jsonNode.has("teamCompetenceScore")) {
throw new AiReportException(AiReportErrorType.AI_RESPONSE_PARSING_FAILED);
}

// 5. 파싱 시도
AiReportResponse response = parseFromJsonNode(jsonNode);

// 6. 파싱된 값이 기본값인지 확인
if (isDefaultResponse(response)) {
log.error("Parsed response is default (all zeros), likely parsing failure");
throw new AiReportException(AiReportErrorType.AI_RESPONSE_PARSING_FAILED);
}

return response;
} catch (Exception e) {
return createDefaultAiReportResponse();
log.error("Failed to parse LLM response. Response: {}", llmResponse, e);
throw new AiReportException(AiReportErrorType.AI_RESPONSE_PARSING_FAILED);
}
}

/**
* JSON 응답 문자열 정리 및 복구
*/
private String cleanJsonResponse(String json) {
if (json == null || json.trim().isEmpty()) {
return "{}";
}

String cleaned = json.trim();

// 1. JSON 코드 블록 마커 제거 (```json ... ``` 또는 ``` ... ```)
if (cleaned.startsWith("```json")) {
cleaned = cleaned.substring(7);
} else if (cleaned.startsWith("```")) {
cleaned = cleaned.substring(3);
}
if (cleaned.endsWith("```")) {
cleaned = cleaned.substring(0, cleaned.length() - 3);
}
cleaned = cleaned.trim();

// 2. "text" 필드에서 JSON 추출 (더 강력한 추출)
// 정규식으로 "text" 필드 추출 시도
if (cleaned.contains("\"text\"") || cleaned.contains("'text'")) {
try {
// 먼저 JSON 파싱 시도
JsonNode root = objectMapper.readTree(cleaned);
if (root.has("text") && root.get("text").isTextual()) {
cleaned = root.get("text").asText();
}
} catch (Exception e) {
// JSON 파싱 실패 시 정규식으로 추출 시도
try {
// "text" : "..." 패턴 찾기
java.util.regex.Pattern pattern = java.util.regex.Pattern.compile(
"\"text\"\\s*:\\s*\"(.*)\"",
java.util.regex.Pattern.DOTALL
);
java.util.regex.Matcher matcher = pattern.matcher(cleaned);
if (matcher.find()) {
String extracted = matcher.group(1);
// 이스케이프된 문자 처리
extracted = extracted.replace("\\n", "\n")
.replace("\\\"", "\"")
.replace("\\\\", "\\");
cleaned = extracted;
log.debug("Extracted text field using regex");
}
} catch (Exception e2) {
log.warn("Failed to extract text field using regex: {}", e2.getMessage());
}
}
}

// 3. 잘못된 따옴표 패턴 수정 (공백이 포함된 필드명)
cleaned = cleaned.replaceAll("\"\\s+([a-zA-Z_][a-zA-Z0-9_]*)\\s+\"", "\"$1\"");

return cleaned;
}

/**
Expand Down Expand Up @@ -165,27 +273,42 @@ private List<AiReportResponse.StrengthWeakness> parseStrengthWeaknessList(JsonNo

/**
* 섹션 점수 리스트 파싱
* 불완전한 항목은 건너뛰거나 기본값으로 대체
*/
private List<AiReportResponse.SectionScoreDetailResponse> parseSectionScores(JsonNode node) {
List<AiReportResponse.SectionScoreDetailResponse> list = new ArrayList<>();
if (node.isArray()) {
for (JsonNode sectionScoreNode : node) {
list.add(new AiReportResponse.SectionScoreDetailResponse(
sectionScoreNode.path("sectionType").asText(""),
sectionScoreNode.path("gradingListScores").asText("[]")));
try {
String sectionType = sectionScoreNode.path("sectionType").asText("");
String gradingListScores = sectionScoreNode.path("gradingListScores").asText("[]");

// gradingListScores가 유효한 JSON 문자열인지 검증
if (!gradingListScores.equals("[]")) {
try {
// JSON 배열 형식인지 확인
if (!gradingListScores.trim().startsWith("[")) {
log.warn("Invalid gradingListScores format for sectionType: {}, using default", sectionType);
gradingListScores = "[]";
} else {
// JSON 파싱 가능 여부 확인
objectMapper.readTree(gradingListScores);
}
} catch (Exception e) {
log.warn("Failed to parse gradingListScores for sectionType: {}, using default. Value: {}",
sectionType, gradingListScores);
gradingListScores = "[]";
}
}

list.add(new AiReportResponse.SectionScoreDetailResponse(sectionType, gradingListScores));
} catch (Exception e) {
log.warn("Failed to parse sectionScore item, skipping: {}", e.getMessage());
// 불완전한 항목은 건너뛰기
}
}
}
return list;
}

/**
* 기본값 AiReportResponse 생성 (파싱 실패 시 사용)
*/
private AiReportResponse createDefaultAiReportResponse() {
return AiReportResponse.fromGradingResult(
0, 0, 0, 0,
new ArrayList<>(),
new ArrayList<>(),
new ArrayList<>());
}
}
Loading
Loading