diff --git a/config b/config index 9a66779b..03c0037b 160000 --- a/config +++ b/config @@ -1 +1 @@ -Subproject commit 9a66779b35ee2c8d9d4b90249fc732e7700dbc4e +Subproject commit 03c0037bd23ba12e28ca71e922f0e1fe525f9714 diff --git a/src/main/java/starlight/adapter/ai/OpenAiChecklistGrader.java b/src/main/java/starlight/adapter/ai/OpenAiChecklistGrader.java index 5d640074..6ea468e4 100644 --- a/src/main/java/starlight/adapter/ai/OpenAiChecklistGrader.java +++ b/src/main/java/starlight/adapter/ai/OpenAiChecklistGrader.java @@ -22,17 +22,14 @@ public class OpenAiChecklistGrader implements ChecklistGrader { @Override public List check( SubSectionType subSectionType, - String newContent, - String previousContent, - List previousChecks + String content ) { - String tag = subSectionType.getTag(); - // 1) 서브섹션별 체크리스트 기준 5개 확보 - List criteria = checklistCatalog.getCriteriaByTag(tag); + List criteria = checklistCatalog.getCriteriaBySubSectionType(subSectionType); + List detailedCriteria = checklistCatalog.getDetailedCriteriaBySubSectionType(subSectionType); // 2) LLM 호출 → Boolean 배열 파싱 - List result = generator.generateChecklistArray(newContent, criteria, previousContent, previousChecks); + List result = generator.generateChecklistArray(subSectionType, content, criteria, detailedCriteria); // 3) 보정: 항상 길이 5 보장 return normalizeToFive(result); diff --git a/src/main/java/starlight/adapter/ai/infra/OpenAiGenerator.java b/src/main/java/starlight/adapter/ai/infra/OpenAiGenerator.java index 209f23e6..62b52455 100644 --- a/src/main/java/starlight/adapter/ai/infra/OpenAiGenerator.java +++ b/src/main/java/starlight/adapter/ai/infra/OpenAiGenerator.java @@ -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; @@ -25,20 +27,26 @@ public class OpenAiGenerator implements LlmGenerator { @Override public List generateChecklistArray( - String newContent, + SubSectionType subSectionType, + String content, List criteria, - String previousContent, - List previousChecks + List 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(); @@ -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(); diff --git a/src/main/java/starlight/adapter/ai/infra/PromptProvider.java b/src/main/java/starlight/adapter/ai/infra/PromptProvider.java index 72d10cb8..9b283363 100644 --- a/src/main/java/starlight/adapter/ai/infra/PromptProvider.java +++ b/src/main/java/starlight/adapter/ai/infra/PromptProvider.java @@ -4,13 +4,30 @@ import org.springframework.ai.chat.messages.Message; import org.springframework.ai.chat.messages.SystemMessage; import org.springframework.ai.chat.messages.UserMessage; +import org.springframework.ai.chat.prompt.PromptTemplate; +import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; +import starlight.domain.businessplan.enumerate.SubSectionType; +import java.util.HashMap; import java.util.List; +import java.util.Map; @Component public class PromptProvider { + @Value("${prompt.report.grading.system}") + private String reportGradingSystemPrompt; + + @Value("${prompt.report.grading.user}") + private String reportGradingUserPromptTemplate; + + @Value("${prompt.checklist.grading.system}") + private String checklistGradingSystemPrompt; + + @Value("${prompt.checklist.grading.user.template}") + private String checklistGradingUserPromptTemplate; + /** * 리포트 채점용 Prompt 객체 생성 */ @@ -22,18 +39,15 @@ public Prompt createReportGradingPrompt(String businessPlanContent) { /** * 체크리스트 채점용 Prompt 객체 생성 - * 이전 정보가 있으면 자동으로 포함하고, 없으면 기본 프롬프트 사용 */ public Prompt createChecklistGradingPrompt( - String newContent, + SubSectionType subSectionType, + String content, List criteria, - String previousContent, - List previousChecks) { - boolean hasPrevious = previousContent != null && !previousContent.isBlank() - && previousChecks != null && !previousChecks.isEmpty(); - - String userPrompt = buildChecklistGradingUserPrompt(newContent, criteria, previousContent, previousChecks); - Message systemMessage = new SystemMessage(getChecklistGradingSystemPrompt(hasPrevious)); + List detailedCriteria + ) { + String userPrompt = buildChecklistGradingUserPrompt(subSectionType, content, criteria, detailedCriteria); + Message systemMessage = new SystemMessage(checklistGradingSystemPrompt); Message userMessage = new UserMessage(userPrompt); return new Prompt(List.of(systemMessage, userMessage)); } @@ -42,206 +56,41 @@ public Prompt createChecklistGradingPrompt( * 리포트 채점용 시스템 프롬프트 */ private String getReportGradingSystemPrompt() { - return """ - 당신은 창업 사업계획서 채점 전문가입니다. 다음 채점 기준에 따라 정확하게 점수를 부여하세요. - - ## 채점 기준 - - ### 문제인식 - ProblemRecognition (총 20점) - ① 근본 원인 논리 분석 [5점] - "이 사업계획서에서 제시된 문제는 단순한 현상 나열이 아닌가요? 작성자는 '왜 이런 문제가 발생했는가'에 대한 원인과 결과를 인과적으로 설명하고 있나요? 원인(정책·산업·행태 등)이 결과(시장/소비자/사업자 변화)로 연결되는 흐름이 논리적으로 드러나나요?" - - ② 다각도 원인 제시 [3점] - "문제의 원인을 다양한 각도에서 분석했나요? 정책적 요인, 산업 구조, 기술 변화, 사용자 행태 등 최소 세 가지 관점이 함께 고려되었나요? 각 관점별로 구체적인 근거나 사례가 제시되었나요?" - - ③ 정량·정성 근거 신뢰도 [5점] - "이 문제 인식은 객관적인 자료에 기반하고 있나요? 정부 통계, 연구 보고서, 설문·인터뷰 등 신뢰할 수 있는 출처를 명시했나요? 수치나 인용문이 실제 문제의 심각성과 직접적으로 연결되나요?" - - ④ 영향·파급력 명시 [3점] - "이 문제가 발생함으로써 누가 어떤 피해나 불이익을 겪고 있나요? 소비자, 사업자, 지역사회 등 주체별 영향을 구체적으로 설명했나요? 해당 문제가 개별 사례를 넘어 산업 전체에 미치는 파급효과를 언급했나요?" - - ⑤ 핵심 문제 포커싱 [4점] - "작성자는 여러 문제 중 핵심이 되는 구조적 문제를 명확히 구분했나요? 글 전체에서 어떤 문제가 중심에 있고, 왜 그 문제가 가장 본질적인지 논리적으로 강조했나요? 문제 간 인과나 우선순위 관계가 드러나나요?" - - ### 실현가능성 - Feasibilitiy (총 30점) - ① 로드맵 구체성 [6점] - "개발과 사업 추진 로드맵이 구체적으로 제시되었나요? 단계별(MVP→베타→정식) 일정, 담당자, 주요 성과물이 명확히 구분되어 있나요? 일정이 시각적으로 표현되었나요?" - - ② 구현체계·자원 현실성 [6점] - "서비스 구현 방식이 구체적으로 설명되었나요? 자체개발, 외주, 협력개발 등 역할 분담이 명확한가요? 인력, 예산, 기간 산출 근거가 현실적인가요?" - - ③ 리스크 식별·대응 [6점] - "사업 추진 중 발생 가능한 리스크를 사전에 인식하고 있나요? 기술적·인적·시장·재무 리스크가 구체적으로 구분되어 있나요? 각 리스크별로 대응 시나리오가 제시되었나요?" - - ④ KPI 설계·측정 계획 [6점] - "단계별 핵심성과지표(KPI)가 명확히 제시되어 있나요? 각 단계의 목표값과 측정 주기가 구체적으로 명시되었나요? 측정 도구(GA, 내부 대시보드 등)가 명시되었나요?" - - ⑤ 경쟁사 분석→차별화 연계 [6점] - "시장 분석이 단순한 데이터 나열이 아니라, 자사 차별화 전략과 연결되어 있나요? 시장 규모, 수요, 경쟁사 비교표가 포함되어 있나요? 분석 결과가 서비스의 차별화 포인트로 이어지나요?" - - ### 성장전략 - GrowthTactic (총 30점) - ① BM 9요소 완결·연계성 [6점] - "비즈니스모델 캔버스 9요소가 빠짐없이 포함되어 있나요? 각 요소 간의 연계가 설명되어 있나요? 핵심활동→고객관계→수익 구조의 흐름이 자연스러운가요?" - - ② 수익모델·매출 추정 [6점] - "수익모델이 구체적으로 설명되어 있나요? 과금 단위(구독·수수료·광고 등)가 명확한가요? 매출 추정 근거(단가×고객수)가 논리적으로 제시되었나요?" - - ③ 자금조달·집행 계획 [6점] - "자금 조달과 사용 계획이 구체적으로 구분되어 있나요? 정부지원, 투자, 매출, 자부담 등 출처가 구분되었나요? 분기별 집행 일정과 후속 투자 전략이 포함되어 있나요?" - - ④ GTM·채널·전환 지표 [6점] - "시장 진입 전략이 구체적인가요? 초기 타깃, 채널, 전환 퍼널이 명확히 구분되어 있나요? 각 전환 단계의 목표 수치가 제시되어 있나요?" - - ⑤ 확장 전략 [6점] - "이 사업의 확장 계획이 구체적으로 제시되어 있나요? 지역, 타깃, 제품군 등 확장 축이 분명한가요? 3~5년 단위의 성장 로드맵이 수치 기반으로 제시되었나요?" - - ### 팀 역량 - TeamCompetence (총 20점) - ① 창업자 전문성·연관성 [5점] - "창업자의 경력과 사업 아이템 간 연관성이 있나요? 산업·기술·도메인 관련 경험이나 자격이 명시되어 있나요? 실제 유사 프로젝트나 직무 경험이 제시되었나요?" - - ② 팀 밸런스·R&R 명확성 [5점] - "팀이 기획, 개발, 디자인, 운영 등 핵심 기능을 균형 있게 보유하고 있나요? 각 팀원의 역할과 책임이 명확히 구분되어 있나요?" - - ③ 경력·전공의 적합성 [4점] - "팀원들의 전공과 경력이 사업 주제와 직접적으로 연결되나요? 기술/비즈니스/디자인 영역별 전문성이 보완적으로 구성되어 있나요?" - - ④ 협업 체계·외부 네트워크 [3점] - "팀 내부 협업 체계가 명확히 보이나요? 협업 도구(Notion, Figma 등)나 회의 리듬이 구체적으로 서술되었나요? 외부 멘토, 기관, 파트너와의 네트워크가 실질적으로 존재하나요?" - - ⑤ 지속 실행력 [3점] - "팀이 장기적으로 사업을 지속할 수 있는 인력 유지/보강 체계를 갖추고 있나요? 핵심인력의 공백에 대비한 대체·채용 계획이 있나요? 조직 운영과 지식 관리 방안이 명시되어 있나요?" - - ## 출력 형식 - 다음 JSON 형식으로 정확하게 응답하세요: - { - "problemRecognitionScore": 0-20, - "feasibilityScore": 0-30, - "growthStrategyScore": 0-30, - "teamCompetenceScore": 0-20, - "strengths": [ - {"title": "장점 1 제목", "content": "장점 1 내용"}, - {"title": "장점 2 제목", "content": "장점 2 내용"}, - {"title": "장점 3 제목", "content": "장점 3 내용"} - ], - "weaknesses": [ - {"title": "단점 1 제목", "content": "단점 1 내용"}, - {"title": "단점 2 제목", "content": "단점 2 내용"}, - {"title": "단점 3 제목", "content": "단점 3 내용"} - ], - "sectionScores": [ - { - "sectionType": "PROBLEM_RECOGNITION", - "gradingListScores": "[{ "item": "근본 원인 논리 분석", "score": 5, "maxScore": 5 }, { "item": "다각도 원인 제시", "score": 3, "maxScore": 3 }, { "item": "정량·정성 근거 신뢰도", "score": 5, "maxScore": 5 }, { "item": "영향·파급력 명시", "score": 3, "maxScore": 3 }, { "item": "핵심 문제 포커싱", "score": 4, "maxScore": 4 }]" - }, - { - "sectionType": "FEASIBILITY", - "gradingListScores": "[{ "item": "로드맵 구체성", "score": 6, "maxScore": 6 }, { "item": "구현체계·자원 현실성", "score": 6, "maxScore": 6 }, { "item": "리스크 식별·대응", "score": 6, "maxScore": 6 }, { "item": "KPI 설계·측정 계획", "score": 6, "maxScore": 6 }, { "item": "경쟁사 분석→차별화 연계", "score": 6, "maxScore": 6 }]" - }, - { - "sectionType": "GROWTH_STRATEGY", - "gradingListScores": "[{ "item": "BM 9요소 완결·연계성", "score": 6, "maxScore": 6 }, { "item": "수익모델·매출 추정", "score": 6, "maxScore": 6 }, { "item": "자금조달·집행 계획", "score": 6, "maxScore": 6 }, { "item": "GTM·채널·전환 지표", "score": 6, "maxScore": 6 }, { "item": "확장 전략", "score": 6, "maxScore": 6 }]" - }, - { - "sectionType": "TEAM_COMPETENCE", - "gradingListScores": "[{ "item": "창업자 전문성·연관성", "score": 5, "maxScore": 5 }, { "item": "팀 밸런스·R&R 명확성", "score": 5, "maxScore": 5 }, { "item": "경력·전공의 적합성", "score": 4, "maxScore": 4 }, { "item": "협업 체계·외부 네트워크", "score": 3, "maxScore": 3 }, { "item": "지속 실행력", "score": 3, "maxScore": 3 }]" - } - ] - } - - - strengths와 weaknesses는 각각 정확히 3개씩 제공해야 합니다. - - gradingListScores는 각 항목별 점수를 JSON 배열 형태로 제공하세요. 문제인식은 5개 항목, 실현가능성은 5개 항목, 성장전략은 5개 항목, 팀역량은 5개 항목입니다. - - item 필드에는 위 채점 기준의 각 항목명을 간략하게 담아주세요 (예: "근본 원인 논리 분석", "로드맵 구체성") - - problemRecognitionScore, feasibilityScore, growthStrategyScore, teamCompetenceScore는 각 섹션의 세부 항목들 점수의 합이므로 이를 꼭 지켜주세요 - """; + return reportGradingSystemPrompt; } /** * 리포트 채점용 사용자 프롬프트 생성 */ private String buildReportGradingUserPrompt(String businessPlanContent) { - return "다음 사업계획서 내용을 채점해주세요:\n\n" + businessPlanContent; - } - - /** - * 체크리스트 채점용 시스템 프롬프트 - * 이전 정보가 있으면 이전 정보를 참고하는 프롬프트, 없으면 기본 프롬프트 - */ - private String getChecklistGradingSystemPrompt(boolean hasPrevious) { - if (hasPrevious) { - return """ - 당신은 JSON 검증기이자 창업 사업계획서 체크리스트 채점 보조자입니다. 사용자 메시지에는 [CHECKLIST], [PREVIOUS_CONTENT], [PREVIOUS_CHECKLIST_RESULT], [NEW_CONTENT], [REQUEST] 섹션이 포함됩니다. 다음을 엄격히 따르세요: - - - 출력은 오직 JSON 배열(Boolean) 하나만 반환합니다. 다른 텍스트, 주석, 키, 객체, 공백, 줄바꿈 금지. - - true/false 소문자만 사용합니다. - - 배열 길이는 [REQUEST]에 명시된 길이와 정확히 동일해야 합니다. - - - [NEW_CONTENT]와 [PREVIOUS_CONTENT]를 비교하여 변경사항을 분석하세요. - - [PREVIOUS_CHECKLIST_RESULT]를 참고하되, [NEW_CONTENT]의 현재 상태를 기준으로 재평가하세요. - - 이전 내용에서 개선되었거나 새로운 정보가 추가되어 [CHECKLIST] 항목을 만족하게 되었다면 TRUE로 업데이트하세요. - - 이전 내용과 동일하거나 개선되지 않았다면, [NEW_CONTENT]를 기준으로 재평가하여 적절한 값을 반환하세요. - - [CHECKLIST] 항목의 순서를 정확히 지켜서 true, false 둘중 하나를 리턴해야야 합니다. - - - 도메인 가이드: TAM/SAM/SOM, SWOT/PEST(STEEP), KPI, 제품/기능 로드맵, 자금 조달/집행(정부지원금·투자·매출·자부담), 시장 진입/확장 전략, 팀 R&R 등 용어를 정확히 해석하세요. - - - 과잉 일반화, 환각, 추측 금지. 명시 근거가 없으면 false입니다. - - 체크리스트 순서를 바꾸지 마세요. - """; - } else { - return """ - 당신은 JSON 검증기이자 창업 사업계획서 체크리스트 채점 보조자입니다. 사용자 메시지에는 [CHECKLIST], [INPUT], [REQUEST] 섹션이 포함됩니다. 다음을 엄격히 따르세요: - - - 출력은 오직 JSON 배열(Boolean) 하나만 반환합니다. 다른 텍스트, 주석, 키, 객체, 공백, 줄바꿈 금지. - - true/false 소문자만 사용합니다. - - 배열 길이는 [REQUEST]에 명시된 길이와 정확히 동일해야 합니다. - - [INPUT]에 대한 내용을 [CHECKLIST] 의 내용으로 순서를 지켜 true, false로 판단해주면 됩니다. 근거 부재 시 false로 판정합니다. 판단은 [CHECKLIST]의 내용에 한정합니다. 해당 내용에 순서를 맞춰 true, false를 반환해주면 됩니다. - - 도메인 가이드: TAM/SAM/SOM, SWOT/PEST(STEEP), KPI, 제품/기능 로드맵, 자금 조달/집행(정부지원금·투자·매출·자부담), 시장 진입/확장 전략, 팀 R&R 등 용어를 정확히 해석하세요. - - - 과잉 일반화, 환각, 추측 금지. 명시 근거가 없으면 false입니다. - - 체크리스트 순서를 바꾸지 마세요. - """; - } + PromptTemplate promptTemplate = new PromptTemplate(reportGradingUserPromptTemplate); + Map variables = Map.of("businessPlanContent", businessPlanContent); + return promptTemplate.render(variables); } /** * 체크리스트 채점용 사용자 프롬프트 생성 - * 이전 정보가 있으면 포함하고, 없으면 기본 프롬프트 생성 */ private String buildChecklistGradingUserPrompt( - String newContent, + SubSectionType subSectionType, + String content, List criteria, - String previousContent, - List previousChecks) { - StringBuilder sb = new StringBuilder(); - sb.append("[CHECKLIST]\n"); - for (int i = 0; i < criteria.size(); i++) { - sb.append(i + 1).append(") ").append(criteria.get(i)).append("\n"); + List detailedCriteria) { + // 체크리스트 상세 기준 포맷팅 + StringBuilder criteriaBuilder = new StringBuilder(); + for (int i = 0; i < criteria.size() && i < detailedCriteria.size(); i++) { + criteriaBuilder.append(i + 1).append(") ").append(criteria.get(i)).append("\n"); + criteriaBuilder.append(detailedCriteria.get(i)).append("\n\n"); } + String formattedCriteria = criteriaBuilder.toString().trim(); - boolean hasPrevious = previousContent != null && !previousContent.isBlank() - && previousChecks != null && !previousChecks.isEmpty(); - - // 이전 정보가 있으면 추가 - if (hasPrevious) { - sb.append("\n[PREVIOUS_CONTENT]\n").append(previousContent).append("\n"); - sb.append("\n[PREVIOUS_CHECKLIST_RESULT]\n"); - for (int i = 0; i < previousChecks.size() && i < criteria.size(); i++) { - sb.append(i + 1).append(") ").append(previousChecks.get(i) ? "TRUE" : "FALSE").append("\n"); - } - sb.append("\n[NEW_CONTENT]\n").append(newContent).append("\n\n"); - sb.append("[REQUEST]\n"); - sb.append("위의 [NEW_CONTENT]를 [PREVIOUS_CONTENT]와 비교하여 변경사항을 확인하고, ") - .append("[PREVIOUS_CHECKLIST_RESULT]를 참고하여 [CHECKLIST] 항목 각각에 대해 업데이트된 판단을 내려주세요. ") - .append("이전 내용에서 개선되었거나 추가된 부분이 있으면 해당 항목을 TRUE로, ") - .append("이전 내용과 동일하거나 개선되지 않은 부분은 이전 결과를 유지하되, ") - .append("새로운 내용을 기준으로 재평가하여 최종 판단해주세요. ") - .append("최종 출력은 길이 ").append(criteria.size()).append("의 JSON 배열(Boolean)로만 반환하세요."); - } else { - sb.append("\n[INPUT]\n").append(newContent).append("\n\n"); - sb.append("[REQUEST]\n"); - sb.append("위의 [INPUT]을 [CHECKLIST] 항목 각각에 대해 판단하여 TRUE/FALSE로만 판단하되, 최종 출력은 길이 ") - .append(criteria.size()).append("의 JSON 배열(Boolean)로만 반환"); - } + Map variables = new HashMap<>(); + variables.put("subsectionType", subSectionType.getDescription()); + variables.put("checklistCriteria", formattedCriteria); + variables.put("input", content); + variables.put("requestLength", criteria.size()); - return sb.toString(); + PromptTemplate promptTemplate = new PromptTemplate(checklistGradingUserPromptTemplate); + return promptTemplate.render(variables); } } diff --git a/src/main/java/starlight/adapter/ai/util/AiReportResponseParser.java b/src/main/java/starlight/adapter/ai/util/AiReportResponseParser.java index 5bdb02b7..7d5c44b8 100644 --- a/src/main/java/starlight/adapter/ai/util/AiReportResponseParser.java +++ b/src/main/java/starlight/adapter/ai/util/AiReportResponseParser.java @@ -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; @@ -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; } /** @@ -165,27 +273,42 @@ private List parseStrengthWeaknessList(JsonNo /** * 섹션 점수 리스트 파싱 + * 불완전한 항목은 건너뛰거나 기본값으로 대체 */ private List parseSectionScores(JsonNode node) { List 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<>()); - } } diff --git a/src/main/java/starlight/adapter/ai/util/ChecklistCatalog.java b/src/main/java/starlight/adapter/ai/util/ChecklistCatalog.java index 9a315e34..86a5236e 100644 --- a/src/main/java/starlight/adapter/ai/util/ChecklistCatalog.java +++ b/src/main/java/starlight/adapter/ai/util/ChecklistCatalog.java @@ -1,83 +1,65 @@ package starlight.adapter.ai.util; +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.stereotype.Component; +import starlight.domain.businessplan.enumerate.SubSectionType; import java.util.List; import java.util.Map; -import static java.util.Map.entry; +import java.util.stream.Collectors; @Component +@ConfigurationProperties(prefix = "prompt.checklist") +@Getter +@Setter public class ChecklistCatalog { - private static final Map> TAG_TO_CRITERIA = Map.ofEntries( - entry("overview_basic", List.of( - "한줄 소개 명확성: 문제와 해결방향의 일문 요약", - "타깃군 구체성: 행동·상황·동기·환경 기반의 세분 정의", - "아이템명 적합성: 핵심 가치와 해결 방향의 직관적 반영", - "보유 기술 근거성: 기능 구현 근거 또는 기술 확보 계획 제시", - "기능 체계성: 핵심–보조–확장 기능의 계층 및 우선순위 정리")), - entry("problem_background", List.of( - "문제 원인·영향 논리성: 근본 원인과 영향 관계의 논리적 서술", - "정량 근거 충실성: 데이터·통계·인터뷰·사례 기반의 문제 입증", - "외부 요인 반영성: 정책 변화·산업 구조 등 거시적 요인 고려", - "핵심 문제 부각성: 핵심 문제의 명확한 강조 (굵은 문장, 수치 등)", - "현장 검증 구체성: 직접 관찰·인터뷰 등 현장 인식 근거 제시")), - entry("problem_purpose", List.of( - "핵심 목적 명확성: 문제와 해결방식이 구체적으로 연결되어 제시됨", - "아이템 필요성: Why Now에 대한 정량·정성 근거 제시", - "해결 방식 구체성: 무엇을 어떻게 해결할지의 실행 방향 제시", - "사회적 연계성: 시장 경쟁력 및 사회적 가치 창출과의 연관성", - "핵심 원인 분석성: 원인–결과 구조를 통한 본질적 문제 도출")), - entry("problem_market", List.of( - "시장 규모 정의성: TAM·SAM·SOM 단계별 수치 및 산출 근거 제시", - "아이템 적합성: 플랫폼·제품 구조의 문제 해결 적합 근거 제시", - "시각화 명확성: 그래프·표를 통한 시장 규모 및 특성 시각화", - "환경분석 체계성: SWOT·STEEP 등 외부 요인 분석 수행", - "실증 검증성: 데이터 외 현장 조사·인터뷰 기반 문제 검증")), - entry("feasibility_strategy", List.of( - "추진 일정 구체성: 개발 기간 및 월 단위 일정 제시", - "로드맵 시각성: 단계별 일정의 시각적 로드맵화", - "구현 체계 명확성: 내부·외주·협력 등 개발 방식 명시", - "리스크 대응성: 예상 위험요소 및 대응 방안 제시", - "성과관리 체계성: 단계별 목표 및 KPI 설정")), - entry("feasibility_market", List.of( - "시장 분석 객관성: 시장 규모·수요의 데이터 기반 분석", - "경쟁사 분석 명확성: 유사 서비스 특징 및 한계점 도출", - "차별화 전략 구체성: 경쟁사 한계 보완을 통한 자사 강점 제시", - "핵심 기능 논리성: 서비스 주요 기능의 구체적·논리적 설명", - "확장 전략 연계성: 분석 결과의 지역·타깃·서비스 확장 계획 반영")), - entry("growth_model", List.of( - "구성 요소 완전성: 비즈니스 모델 캔버스 9요소 포함", - "수익 구조 명확성: 수익원 및 과금 단위의 구체적 정의", - "매출 추정 근거성: 단가·예상 고객 수 기반의 계산 제시", - "비용 구조 현실성: 주요 비용 항목과 수익 구조의 일관성 확보", - "지불 의향 검증성: 고객군의 지불 의향 조사 및 근거 제시")), - entry("growth_funding", List.of( - "조달 구조 명확성: 정부지원금·투자금·매출·자부담 등 항목별 금액·비율 구분", - "자금 사용 구체성: 개발·운영·마케팅 등 단계별 사용 계획 수치화", - "집행 일정 현실성: 분기·연도별 자금 집행 일정 제시", - "성과 연계성: 각 지출과 성과지표 달성 간의 연계 설명", - "투자 전망성: 후속 투자 및 외부 펀딩 전략 제시")), - entry("growth_entry", List.of( - "목표 시장 구체성: 국내·해외·지역 등 진입 대상 시장 명확화", - "단계별 확장성: 초기 진입시장과 확장시장 로드맵 구분 제시", - "성과지표 구체성: 매출·사용자·거래건수 등 목표 수치화", - "실행 전략 현실성: 시장 진입 방식 및 운영 전략의 실현 가능성 확보", - "성장 방향 명확성: 아이템의 중·장기 성장 방향 제시")), - entry("team_founder", List.of( - "전문 경력 연관성: 자격·경험의 사업 아이템 관련성 확보", - "학력·전공 적합성: 출신 학교·전공의 사업 연계성 제시", - "도전정신 함양 여부: 고객 문제 이해 및 해결하는 통찰력 보유", - "리더십·관리 역량: 목표 설정·일정 관리·팀 운영 능력 보유", - "실행 자원 확보성: 네트워크 및 사업 추진 자원 확보")), - entry("team_members", List.of( - "팀 균형성: 핵심 기능별 인력 구성의 균형 확보", - "역할·책임 명확성: 구성원별 R&R의 구체적 정의", - "경험 연관성: 팀원 경력과 사업 아이템의 관련성 제시", - "전공 적합성: 출신 학교·전공의 사업 연계성 확보", - "팀 우선순위 명확성: 팀원별 기여도 및 핵심 역할의 우선순위 구분"))); + private Map catalog; - public List getCriteriaByTag(String tag) { - return TAG_TO_CRITERIA.getOrDefault(tag, List.of()); - } + @Getter + @Setter + public static class CatalogSection { + private List items; + } + + @Getter + @Setter + public static class ChecklistItem { + private String criteria; + private String detailed; + } + + // 서브섹션 타입에 해당하는 criteria 리스트를 반환합니다 + public List getCriteriaBySubSectionType(SubSectionType subSectionType) { + String tag = subSectionType.getTag(); + if (catalog == null || !catalog.containsKey(tag)) { + return List.of(); + } + CatalogSection section = catalog.get(tag); + if (section == null || section.getItems() == null) { + return List.of(); + } + return section.getItems().stream() + .map(ChecklistItem::getCriteria) + .filter(c -> c != null && !c.isEmpty()) + .collect(Collectors.toList()); + } + + // 서브섹션 타입에 해당하는 detailed-criteria 리스트를 반환합니다. + public List getDetailedCriteriaBySubSectionType(SubSectionType subSectionType) { + String tag = subSectionType.getTag(); + if (catalog == null || !catalog.containsKey(tag)) { + return List.of(); + } + CatalogSection section = catalog.get(tag); + if (section == null || section.getItems() == null) { + return List.of(); + } + return section.getItems().stream() + .map(ChecklistItem::getDetailed) + .filter(d -> d != null && !d.isEmpty()) + .collect(Collectors.toList()); + } } diff --git a/src/main/java/starlight/adapter/businessplan/webapi/BusinessPlanController.java b/src/main/java/starlight/adapter/businessplan/webapi/BusinessPlanController.java index 69f5e9fe..fcc9d431 100644 --- a/src/main/java/starlight/adapter/businessplan/webapi/BusinessPlanController.java +++ b/src/main/java/starlight/adapter/businessplan/webapi/BusinessPlanController.java @@ -5,6 +5,7 @@ import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; +import jakarta.validation.constraints.Min; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; @@ -37,8 +38,8 @@ public class BusinessPlanController { @Operation(summary = "사업 계획서 목록을 조회합니다. (마이페이지 용)") public ApiResponse getBusinessPlanList( @AuthenticationPrincipal AuthDetails authDetails, - @Parameter(description = "페이지 번호 (1 이상 정수)") @RequestParam(defaultValue = "1") int page, - @Parameter(description = "페이지 크기 (기본 3)") @RequestParam(defaultValue = "3") int size + @Parameter(description = "페이지 번호 (1 이상 정수 / 기본 1)") @RequestParam(defaultValue = "1") @Min(1)int page, + @Parameter(description = "페이지 크기 (1 이상 정수 / 기본 3)") @RequestParam(defaultValue = "3") @Min(1) int size ) { int zeroBasedPage = Math.max(0, page - 1); Pageable pageable = PageRequest.of(zeroBasedPage, size); diff --git a/src/main/java/starlight/adapter/businessplan/webapi/dto/SubSectionCreateRequest.java b/src/main/java/starlight/adapter/businessplan/webapi/dto/SubSectionCreateRequest.java index 4914fc90..baf05c77 100644 --- a/src/main/java/starlight/adapter/businessplan/webapi/dto/SubSectionCreateRequest.java +++ b/src/main/java/starlight/adapter/businessplan/webapi/dto/SubSectionCreateRequest.java @@ -13,7 +13,7 @@ public record SubSectionCreateRequest( @NotNull SubSectionType subSectionType, @NotNull List checks, - @Valid @NotNull SubSectionCreateRequest.Meta meta, + @Valid @NotNull Meta meta, @Valid @NotNull List<@Valid Block> blocks) { public record Meta( @NotBlank String author, @@ -21,7 +21,7 @@ public record Meta( } public record Block( - @Valid @NotNull SubSectionCreateRequest.BlockMeta meta, + @Valid @NotNull BlockMeta meta, @Valid List<@Valid Content> content) { } @@ -31,18 +31,18 @@ public record BlockMeta( @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.EXISTING_PROPERTY, property = "type", visible = true) @JsonSubTypes({ - @JsonSubTypes.Type(value = SubSectionCreateRequest.TextItem.class, name = "text"), - @JsonSubTypes.Type(value = SubSectionCreateRequest.ImageItem.class, name = "image"), - @JsonSubTypes.Type(value = SubSectionCreateRequest.TableItem.class, name = "table") + @JsonSubTypes.Type(value = TextItem.class, name = "text"), + @JsonSubTypes.Type(value = ImageItem.class, name = "image"), + @JsonSubTypes.Type(value = TableItem.class, name = "table") }) public sealed interface Content - permits SubSectionCreateRequest.TextItem, SubSectionCreateRequest.ImageItem, SubSectionCreateRequest.TableItem { + permits TextItem, ImageItem, TableItem { String type(); } public record TextItem( @NotBlank String type, - @NotBlank String value) implements SubSectionCreateRequest.Content { + @NotBlank String value) implements Content { } public record ImageItem( @@ -50,7 +50,7 @@ public record ImageItem( @NotBlank @Size(max = 1024) String src, @JsonProperty(defaultValue = "400") Integer width, @JsonProperty(defaultValue = "400") Integer height, - @Size(max = 255) String caption) implements SubSectionCreateRequest.Content { + @Size(max = 255) String caption) implements Content { public ImageItem { width = width != null ? width : 400; height = height != null ? height : 400; @@ -60,7 +60,7 @@ public record ImageItem( public record TableItem( @NotBlank String type, @NotEmpty List<@NotBlank String> columns, - @NotEmpty List<@NotEmpty List> rows) implements SubSectionCreateRequest.Content { + @NotEmpty List<@NotEmpty List> rows) implements Content { @AssertTrue(message = "table rows must match columns length") @JsonIgnore diff --git a/src/main/java/starlight/application/businessplan/BusinessPlanServiceImpl.java b/src/main/java/starlight/application/businessplan/BusinessPlanServiceImpl.java index 905aa1b4..ba5247fa 100644 --- a/src/main/java/starlight/application/businessplan/BusinessPlanServiceImpl.java +++ b/src/main/java/starlight/application/businessplan/BusinessPlanServiceImpl.java @@ -183,17 +183,14 @@ public List checkAndUpdateSubSection( throw new BusinessPlanException(BusinessPlanErrorType.SUBSECTION_NOT_FOUND); } - String newContent = PlainTextExtractUtils.extractPlainText(objectMapper, jsonNode); - - String previousContent = subSection.getContent(); - List previousChecks = subSection.getChecks(); + String content = PlainTextExtractUtils.extractPlainText(objectMapper, jsonNode); - List checks = checklistGrader.check(subSectionType, newContent, previousContent, previousChecks); + List checks = checklistGrader.check(subSectionType, content); SubSectionSupportUtils.requireSize(checks, SubSection.getCHECKLIST_SIZE()); String rawJsonStr = getSerializedJsonNodesWithUpdatedChecks(jsonNode, checks); - subSection.update(newContent, rawJsonStr, checks); + subSection.update(content, rawJsonStr, checks); businessPlanQuery.save(plan); diff --git a/src/main/java/starlight/application/businessplan/required/ChecklistGrader.java b/src/main/java/starlight/application/businessplan/required/ChecklistGrader.java index 8323ed9b..33049e2a 100644 --- a/src/main/java/starlight/application/businessplan/required/ChecklistGrader.java +++ b/src/main/java/starlight/application/businessplan/required/ChecklistGrader.java @@ -10,15 +10,11 @@ public interface ChecklistGrader { * 서브섹션 내용을 체크리스트 기준에 따라 체크합니다. * * @param subSectionType 서브섹션 타입 - * @param newContent 새로운 서브섹션 내용 - * @param previousContent 이전 서브섹션 내용 (없으면 null) - * @param previousChecks 이전 체크리스트 결과 (없으면 null) + * @param content 서브섹션 내용 * @return 체크리스트 결과 */ List check( SubSectionType subSectionType, - String newContent, - String previousContent, - List previousChecks + String content ); } diff --git a/src/main/java/starlight/application/infrastructure/provided/LlmGenerator.java b/src/main/java/starlight/application/infrastructure/provided/LlmGenerator.java index 76715cef..58b78684 100644 --- a/src/main/java/starlight/application/infrastructure/provided/LlmGenerator.java +++ b/src/main/java/starlight/application/infrastructure/provided/LlmGenerator.java @@ -1,10 +1,12 @@ package starlight.application.infrastructure.provided; +import starlight.domain.businessplan.enumerate.SubSectionType; + import java.util.List; public interface LlmGenerator { - List generateChecklistArray(String newContent, List criteria, String previousContent, List previousChecks); + List generateChecklistArray(SubSectionType subSectionType, String content, List criteria, List detailedCriteria); String generateReport(String content); } diff --git a/src/main/java/starlight/domain/aireport/exception/AiReportErrorType.java b/src/main/java/starlight/domain/aireport/exception/AiReportErrorType.java index af271a22..95b85996 100644 --- a/src/main/java/starlight/domain/aireport/exception/AiReportErrorType.java +++ b/src/main/java/starlight/domain/aireport/exception/AiReportErrorType.java @@ -11,7 +11,8 @@ public enum AiReportErrorType implements ErrorType { AI_REPORT_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 AI 리포트가 존재하지 않습니다."), NOT_READY_FOR_AI_REPORT(HttpStatus.BAD_REQUEST, "사업계획서가 작성 완료되지 않아 AI 리포트를 생성할 수 없습니다."), - UNAUTHORIZED_ACCESS(HttpStatus.FORBIDDEN, "권한이 없습니다."); + UNAUTHORIZED_ACCESS(HttpStatus.FORBIDDEN, "권한이 없습니다."), + AI_RESPONSE_PARSING_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "AI 응답 파싱에 실패했습니다."); ; private final HttpStatus status; diff --git a/src/main/java/starlight/domain/businessplan/enumerate/SubSectionType.java b/src/main/java/starlight/domain/businessplan/enumerate/SubSectionType.java index e29d9ead..4e09fb81 100644 --- a/src/main/java/starlight/domain/businessplan/enumerate/SubSectionType.java +++ b/src/main/java/starlight/domain/businessplan/enumerate/SubSectionType.java @@ -9,27 +9,27 @@ public enum SubSectionType { // 개요 (OVERVIEW) - OVERVIEW_BASIC("개요", SectionType.OVERVIEW, "overview"), + OVERVIEW_BASIC("개요", SectionType.OVERVIEW, "overview_basic"), // 문제 인식 (PROBLEM_RECOGNITION) - PROBLEM_BACKGROUND("창업 배경 및 개발동기", SectionType.PROBLEM_RECOGNITION, "problem_recognition"), - PROBLEM_PURPOSE("창업아이템의 목적 및 필요성", SectionType.PROBLEM_RECOGNITION, "problem_recognition"), - PROBLEM_MARKET("창업아이템의 목표시장 분석", SectionType.PROBLEM_RECOGNITION, "problem_recognition"), + PROBLEM_BACKGROUND("창업 배경 및 개발동기", SectionType.PROBLEM_RECOGNITION, "problem_background"), + PROBLEM_PURPOSE("창업아이템의 목적 및 필요성", SectionType.PROBLEM_RECOGNITION, "problem_purpose"), + PROBLEM_MARKET("창업아이템의 목표시장 분석", SectionType.PROBLEM_RECOGNITION, "problem_market"), // 실현 가능성 (FEASIBILITY) - FEASIBILITY_STRATEGY("사업화 전략", SectionType.FEASIBILITY, "feasibility"), - FEASIBILITY_MARKET("시장분석 및 경쟁력 확보 방안", SectionType.FEASIBILITY, "feasibility"), + FEASIBILITY_STRATEGY("사업화 전략", SectionType.FEASIBILITY, "feasibility_strategy"), + FEASIBILITY_MARKET("시장분석 및 경쟁력 확보 방안", SectionType.FEASIBILITY, "feasibility_market"), // 성장 전략 (GROWTH_STRATEGY) - GROWTH_MODEL("비즈니스 모델", SectionType.GROWTH_STRATEGY, "growth_tactic"), - GROWTH_FUNDING("자금조달 계획", SectionType.GROWTH_STRATEGY, "growth_tactic"), - GROWTH_ENTRY("시장진입 및 성과창출 전략", SectionType.GROWTH_STRATEGY, "growth_tactic"), + GROWTH_MODEL("비즈니스 모델", SectionType.GROWTH_STRATEGY, "growth_model"), + GROWTH_FUNDING("자금조달 계획", SectionType.GROWTH_STRATEGY, "growth_funding"), + GROWTH_ENTRY("시장진입 및 성과창출 전략", SectionType.GROWTH_STRATEGY, "growth_entry"), // 팀 역량 (TEAM_COMPETENCE) - TEAM_FOUNDER("창업자의 역량", SectionType.TEAM_COMPETENCE, "team_competence"), - TEAM_MEMBERS("팀 역량", SectionType.TEAM_COMPETENCE, "team_competence"); + TEAM_FOUNDER("창업자의 역량", SectionType.TEAM_COMPETENCE, "team_founder"), + TEAM_MEMBERS("팀 역량", SectionType.TEAM_COMPETENCE, "team_members"); private final String description; private final SectionType sectionType; - private final String tag; // RAG tag 용도 + private final String tag; } \ No newline at end of file diff --git a/src/test/java/starlight/adapter/ai/AiChecklistGraderTest.java b/src/test/java/starlight/adapter/ai/AiChecklistGraderTest.java index a39ee7d9..460483f4 100644 --- a/src/test/java/starlight/adapter/ai/AiChecklistGraderTest.java +++ b/src/test/java/starlight/adapter/ai/AiChecklistGraderTest.java @@ -2,7 +2,6 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; -import starlight.adapter.ai.OpenAiChecklistGrader; import starlight.adapter.ai.infra.OpenAiGenerator; import starlight.adapter.ai.util.ChecklistCatalog; import starlight.domain.businessplan.enumerate.SubSectionType; @@ -19,53 +18,44 @@ class AiChecklistGraderTest { @DisplayName("criteria별 컨텍스트를 합치고 LLM 결과를 반환") void check_returnsFromLlm() { OpenAiGenerator generator = mock(OpenAiGenerator.class); - when(generator.generateChecklistArray(anyString(), anyList(), isNull(), isNull())) + when(generator.generateChecklistArray(any(SubSectionType.class), anyString(), anyList(), anyList())) .thenReturn(List.of(true, false, true, false, true)); ChecklistCatalog catalog = mock(ChecklistCatalog.class); - when(catalog.getCriteriaByTag(anyString())) + when(catalog.getCriteriaBySubSectionType(any(SubSectionType.class))) .thenReturn(List.of("c1", "c2", "c3", "c4", "c5")); + when(catalog.getDetailedCriteriaBySubSectionType(any(SubSectionType.class))) + .thenReturn(List.of("d1", "d2", "d3", "d4", "d5")); OpenAiChecklistGrader sut = new OpenAiChecklistGrader(generator, catalog); - List result = sut.check(SubSectionType.OVERVIEW_BASIC, "input text", null, null); + List result = sut.check(SubSectionType.OVERVIEW_BASIC, "input text"); assertThat(result).containsExactly(true, false, true, false, true); - verify(generator).generateChecklistArray(eq("input text"), anyList(), isNull(), isNull()); + verify(generator).generateChecklistArray( + eq(SubSectionType.OVERVIEW_BASIC), + eq("input text"), + eq(List.of("c1", "c2", "c3", "c4", "c5")), + eq(List.of("d1", "d2", "d3", "d4", "d5")) + ); + verify(catalog).getCriteriaBySubSectionType(SubSectionType.OVERVIEW_BASIC); + verify(catalog).getDetailedCriteriaBySubSectionType(SubSectionType.OVERVIEW_BASIC); } @Test @DisplayName("LLM 결과 길이가 5보다 짧으면 false로 패딩") void check_normalizesToFive() { OpenAiGenerator generator = mock(OpenAiGenerator.class); - when(generator.generateChecklistArray(anyString(), anyList(), isNull(), isNull())) + when(generator.generateChecklistArray(any(SubSectionType.class), anyString(), anyList(), anyList())) .thenReturn(List.of(true)); ChecklistCatalog catalog = mock(ChecklistCatalog.class); - when(catalog.getCriteriaByTag(anyString())) + when(catalog.getCriteriaBySubSectionType(any(SubSectionType.class))) .thenReturn(List.of("c1", "c2", "c3", "c4", "c5")); + when(catalog.getDetailedCriteriaBySubSectionType(any(SubSectionType.class))) + .thenReturn(List.of("d1", "d2", "d3", "d4", "d5")); OpenAiChecklistGrader sut = new OpenAiChecklistGrader(generator, catalog); - List result = sut.check(SubSectionType.OVERVIEW_BASIC, "input text", null, null); + List result = sut.check(SubSectionType.OVERVIEW_BASIC, "input text"); assertThat(result).containsExactly(true, false, false, false, false); } - - @Test - @DisplayName("이전 정보가 있으면 이전 정보를 포함하여 체크") - void check_withPreviousContent() { - OpenAiGenerator generator = mock(OpenAiGenerator.class); - when(generator.generateChecklistArray(eq("new content"), anyList(), eq("previous content"), anyList())) - .thenReturn(List.of(true, true, true, true, true)); - - ChecklistCatalog catalog = mock(ChecklistCatalog.class); - when(catalog.getCriteriaByTag(anyString())) - .thenReturn(List.of("c1", "c2", "c3", "c4", "c5")); - - OpenAiChecklistGrader sut = new OpenAiChecklistGrader(generator, catalog); - - List previousChecks = List.of(false, false, false, false, false); - List result = sut.check(SubSectionType.OVERVIEW_BASIC, "new content", "previous content", previousChecks); - - assertThat(result).containsExactly(true, true, true, true, true); - verify(generator).generateChecklistArray(eq("new content"), anyList(), eq("previous content"), eq(previousChecks)); - } } diff --git a/src/test/java/starlight/adapter/ai/infra/OpenAiGeneratorTest.java b/src/test/java/starlight/adapter/ai/infra/OpenAiGeneratorTest.java index 34131431..73858498 100644 --- a/src/test/java/starlight/adapter/ai/infra/OpenAiGeneratorTest.java +++ b/src/test/java/starlight/adapter/ai/infra/OpenAiGeneratorTest.java @@ -4,12 +4,14 @@ import org.junit.jupiter.api.Test; import org.springframework.ai.chat.client.ChatClient; import org.springframework.ai.chat.prompt.Prompt; +import starlight.domain.businessplan.enumerate.SubSectionType; import java.util.List; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*; +import static org.mockito.Mockito.RETURNS_DEEP_STUBS; class OpenAiGeneratorTest { @@ -20,20 +22,30 @@ void generateChecklistArray_parsesJson() { ChatClient.Builder builder = mock(ChatClient.Builder.class); when(builder.build()).thenReturn(chatClient); - // RETURNS_DEEP_STUBS를 사용하여 체인이 자동으로 처리되도록 하고, - // 실제 content() 호출 시 값을 반환하도록 설정 - lenient().when(chatClient.prompt(any(Prompt.class)).call().content()) - .thenReturn("[true,false,true,false,true]"); + // RETURNS_DEEP_STUBS를 사용하면 체인 전체가 자동으로 mock됨 + // 마지막 content()만 반환값 설정 + when(chatClient.prompt(any(Prompt.class)) + .options(any()) + .advisors(any(org.springframework.ai.chat.client.advisor.SimpleLoggerAdvisor.class)) + .call() + .content()).thenReturn("[true,false,true,false,true]"); PromptProvider promptProvider = mock(PromptProvider.class); - when(promptProvider.createChecklistGradingPrompt(anyString(), anyList(), isNull(), isNull())) + when(promptProvider.createChecklistGradingPrompt(any(SubSectionType.class), anyString(), anyList(), anyList())) .thenReturn(mock(Prompt.class)); AdvisorProvider advisorProvider = mock(AdvisorProvider.class); + when(advisorProvider.getSimpleLoggerAdvisor()) + .thenReturn(mock(org.springframework.ai.chat.client.advisor.SimpleLoggerAdvisor.class)); OpenAiGenerator sut = new OpenAiGenerator(builder, promptProvider, advisorProvider); - List result = sut.generateChecklistArray("test content", List.of("c1", "c2", "c3", "c4", "c5"), null, null); + List result = sut.generateChecklistArray( + SubSectionType.OVERVIEW_BASIC, + "test content", + List.of("c1", "c2", "c3", "c4", "c5"), + List.of("d1", "d2", "d3", "d4", "d5") + ); assertThat(result).containsExactly(true, false, true, false, true); } @@ -44,18 +56,74 @@ void generateChecklistArray_parseFail_returnsAllFalse() { ChatClient.Builder builder = mock(ChatClient.Builder.class); when(builder.build()).thenReturn(chatClient); - lenient().when(chatClient.prompt(any(Prompt.class)).call().content()) - .thenReturn("not-json"); + // RETURNS_DEEP_STUBS를 사용하면 체인 전체가 자동으로 mock됨 + // 마지막 content()만 반환값 설정 + when(chatClient.prompt(any(Prompt.class)) + .options(any()) + .advisors(any(org.springframework.ai.chat.client.advisor.SimpleLoggerAdvisor.class)) + .call() + .content()).thenReturn("not-json"); PromptProvider promptProvider = mock(PromptProvider.class); - when(promptProvider.createChecklistGradingPrompt(anyString(), anyList(), isNull(), isNull())) + when(promptProvider.createChecklistGradingPrompt(any(SubSectionType.class), anyString(), anyList(), anyList())) .thenReturn(mock(Prompt.class)); AdvisorProvider advisorProvider = mock(AdvisorProvider.class); + when(advisorProvider.getSimpleLoggerAdvisor()) + .thenReturn(mock(org.springframework.ai.chat.client.advisor.SimpleLoggerAdvisor.class)); OpenAiGenerator sut = new OpenAiGenerator(builder, promptProvider, advisorProvider); - List result = sut.generateChecklistArray("test content", List.of("c1", "c2", "c3", "c4", "c5"), null, null); + List result = sut.generateChecklistArray( + SubSectionType.OVERVIEW_BASIC, + "test content", + List.of("c1", "c2", "c3", "c4", "c5"), + List.of("d1", "d2", "d3", "d4", "d5") + ); assertThat(result).containsExactly(false, false, false, false, false); } + + @Test + @DisplayName("generateReport는 OpenAI 응답 문자열을 반환한다") + void generateReport_returnsString() { + ChatClient chatClient = mock(ChatClient.class, RETURNS_DEEP_STUBS); + ChatClient.Builder builder = mock(ChatClient.Builder.class); + when(builder.build()).thenReturn(chatClient); + + String expectedResponse = """ + { + "problemRecognitionScore": 20, + "feasibilityScore": 25, + "growthStrategyScore": 30, + "teamCompetenceScore": 20, + "sectionScores": [], + "strengths": [], + "weaknesses": [] + } + """.trim(); + + // RETURNS_DEEP_STUBS를 사용하면 체인 전체가 자동으로 mock됨 + // 마지막 content()만 반환값 설정 + when(chatClient.prompt(any(Prompt.class)) + .options(any()) + .advisors(any(), any()) + .call() + .content()).thenReturn(expectedResponse); + + PromptProvider promptProvider = mock(PromptProvider.class); + when(promptProvider.createReportGradingPrompt(anyString())) + .thenReturn(mock(Prompt.class)); + + AdvisorProvider advisorProvider = mock(AdvisorProvider.class); + when(advisorProvider.getQuestionAnswerAdvisor(anyDouble(), anyInt(), any())) + .thenReturn(mock(org.springframework.ai.chat.client.advisor.vectorstore.QuestionAnswerAdvisor.class)); + when(advisorProvider.getSimpleLoggerAdvisor()) + .thenReturn(mock(org.springframework.ai.chat.client.advisor.SimpleLoggerAdvisor.class)); + + OpenAiGenerator sut = new OpenAiGenerator(builder, promptProvider, advisorProvider); + + String result = sut.generateReport("test content"); + + assertThat(result).isEqualTo(expectedResponse); + } } diff --git a/src/test/java/starlight/adapter/ai/util/AiReportResponseParserTest.java b/src/test/java/starlight/adapter/ai/util/AiReportResponseParserTest.java new file mode 100644 index 00000000..1f5426d4 --- /dev/null +++ b/src/test/java/starlight/adapter/ai/util/AiReportResponseParserTest.java @@ -0,0 +1,151 @@ +package starlight.adapter.ai.util; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import starlight.application.aireport.provided.dto.AiReportResponse; +import starlight.domain.aireport.exception.AiReportException; +import starlight.domain.aireport.exception.AiReportErrorType; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@DisplayName("AiReportResponseParser 테스트") +class AiReportResponseParserTest { + + private final ObjectMapper objectMapper = new ObjectMapper(); + private final AiReportResponseParser parser = new AiReportResponseParser(objectMapper); + + @Test + @DisplayName("유효한 JSON 응답을 파싱한다") + void parse_validJson_returnsResponse() { + // given + String validJson = """ + { + "problemRecognitionScore": 18, + "feasibilityScore": 28, + "growthStrategyScore": 30, + "teamCompetenceScore": 20, + "strengths": [ + {"title": "강점1", "content": "내용1"} + ], + "weaknesses": [ + {"title": "약점1", "content": "내용1"} + ], + "sectionScores": [ + { + "sectionType": "PROBLEM_RECOGNITION", + "gradingListScores": "[{\\"item\\":\\"항목1\\",\\"score\\":5,\\"maxScore\\":5}]" + } + ] + } + """; + + // when + AiReportResponse result = parser.parse(validJson); + + // then + assertThat(result).isNotNull(); + assertThat(result.problemRecognitionScore()).isEqualTo(18); + assertThat(result.feasibilityScore()).isEqualTo(28); + assertThat(result.growthStrategyScore()).isEqualTo(30); + assertThat(result.teamCompetenceScore()).isEqualTo(20); + assertThat(result.strengths()).hasSize(1); + assertThat(result.weaknesses()).hasSize(1); + assertThat(result.sectionScores()).hasSize(1); + } + + @Test + @DisplayName("null 응답 시 예외를 던진다") + void parse_nullResponse_throwsException() { + // when & then + assertThatThrownBy(() -> parser.parse(null)) + .isInstanceOf(AiReportException.class) + .extracting("errorType") + .isEqualTo(AiReportErrorType.AI_RESPONSE_PARSING_FAILED); + } + + @Test + @DisplayName("빈 문자열 응답 시 예외를 던진다") + void parse_emptyResponse_throwsException() { + // when & then + assertThatThrownBy(() -> parser.parse("")) + .isInstanceOf(AiReportException.class) + .extracting("errorType") + .isEqualTo(AiReportErrorType.AI_RESPONSE_PARSING_FAILED); + } + + @Test + @DisplayName("필수 필드가 없는 응답 시 예외를 던진다") + void parse_missingRequiredFields_throwsException() { + // given + String invalidJson = """ + { + "strengths": [], + "weaknesses": [] + } + """; + + // when & then + assertThatThrownBy(() -> parser.parse(invalidJson)) + .isInstanceOf(AiReportException.class) + .extracting("errorType") + .isEqualTo(AiReportErrorType.AI_RESPONSE_PARSING_FAILED); + } + + @Test + @DisplayName("기본값(모두 0) 응답 시 예외를 던진다") + void parse_defaultResponse_throwsException() { + // given + String defaultJson = """ + { + "problemRecognitionScore": 0, + "feasibilityScore": 0, + "growthStrategyScore": 0, + "teamCompetenceScore": 0, + "strengths": [], + "weaknesses": [], + "sectionScores": [] + } + """; + + // when & then + assertThatThrownBy(() -> parser.parse(defaultJson)) + .isInstanceOf(AiReportException.class) + .extracting("errorType") + .isEqualTo(AiReportErrorType.AI_RESPONSE_PARSING_FAILED); + } + + @Test + @DisplayName("text 필드가 있는 응답을 파싱한다") + void parse_textFieldResponse_parsesCorrectly() { + // given + String textFieldJson = """ + { + "text": "{\\"problemRecognitionScore\\": 18, \\"feasibilityScore\\": 28, \\"growthStrategyScore\\": 30, \\"teamCompetenceScore\\": 20, \\"strengths\\": [], \\"weaknesses\\": [], \\"sectionScores\\": []}" + } + """; + + // when + AiReportResponse result = parser.parse(textFieldJson); + + // then + assertThat(result).isNotNull(); + assertThat(result.problemRecognitionScore()).isEqualTo(18); + assertThat(result.feasibilityScore()).isEqualTo(28); + } + + @Test + @DisplayName("잘못된 JSON 형식 시 예외를 던진다") + void parse_invalidJson_throwsException() { + // given + String invalidJson = "not a json"; + + // when & then + assertThatThrownBy(() -> parser.parse(invalidJson)) + .isInstanceOf(AiReportException.class) + .extracting("errorType") + .isEqualTo(AiReportErrorType.AI_RESPONSE_PARSING_FAILED); + } +} + diff --git a/src/test/java/starlight/application/businessplan/BusinessPlanServiceImplIntegrationTest.java b/src/test/java/starlight/application/businessplan/BusinessPlanServiceImplIntegrationTest.java index 29870e9d..35fefd30 100644 --- a/src/test/java/starlight/application/businessplan/BusinessPlanServiceImplIntegrationTest.java +++ b/src/test/java/starlight/application/businessplan/BusinessPlanServiceImplIntegrationTest.java @@ -26,7 +26,8 @@ @DataJpaTest @AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.ANY) -@Import({ BusinessPlanServiceImpl.class, BusinessPlanJpa.class, BusinessPlanServiceImplIntegrationTest.TestBeans.class }) +@Import({ BusinessPlanServiceImpl.class, BusinessPlanJpa.class, + BusinessPlanServiceImplIntegrationTest.TestBeans.class }) class BusinessPlanServiceImplIntegrationTest { @Autowired @@ -40,7 +41,7 @@ class BusinessPlanServiceImplIntegrationTest { static class TestBeans { @Bean ChecklistGrader checklistGrader() { - return (subSectionType, newContent, previousContent, previousChecks) -> List.of(false, false, false, false, false); + return (subSectionType, content) -> List.of(false, false, false, false, false); } @Bean @@ -69,7 +70,8 @@ void create_and_update_title_and_delete_with_subsections_cleanup() { assertThat(planId).isNotNull(); // attach a subsection to overview - SubSection s1 = SubSection.create(SubSectionType.OVERVIEW_BASIC, "c", "{}", List.of(false, false, false, false, false)); + SubSection s1 = SubSection.create(SubSectionType.OVERVIEW_BASIC, "c", "{}", + List.of(false, false, false, false, false)); BusinessPlan createdEntity = businessPlanRepository.findById(planId).orElseThrow(); createdEntity.getOverview().putSubSection(s1); businessPlanRepository.save(createdEntity); @@ -111,6 +113,7 @@ void createBusinessPlanWithPdf_createsPlanWithPdfInfo() { assertThat(createdPlan.getTitle()).isEqualTo(title); assertThat(createdPlan.getPdfUrl()).isEqualTo(pdfUrl); assertThat(createdPlan.getMemberId()).isEqualTo(memberId); - assertThat(createdPlan.getPlanStatus()).isEqualTo(starlight.domain.businessplan.enumerate.PlanStatus.WRITTEN_COMPLETED); + assertThat(createdPlan.getPlanStatus()) + .isEqualTo(starlight.domain.businessplan.enumerate.PlanStatus.WRITTEN_COMPLETED); } } diff --git a/src/test/java/starlight/application/businessplan/BusinessPlanServiceImplUnitTest.java b/src/test/java/starlight/application/businessplan/BusinessPlanServiceImplUnitTest.java index 53b922f8..4c579585 100644 --- a/src/test/java/starlight/application/businessplan/BusinessPlanServiceImplUnitTest.java +++ b/src/test/java/starlight/application/businessplan/BusinessPlanServiceImplUnitTest.java @@ -378,9 +378,7 @@ void checkAndUpdateSubSection_savesChecks() { List updatedChecks = List.of(true, true, true, true, true); when(checklistGrader.check( eq(SubSectionType.OVERVIEW_BASIC), - eq("updated content"), - eq("previous-content"), - anyList())).thenReturn(updatedChecks); + eq("updated content"))).thenReturn(updatedChecks); com.fasterxml.jackson.databind.ObjectMapper realObjectMapper = new com.fasterxml.jackson.databind.ObjectMapper(); com.fasterxml.jackson.databind.node.ObjectNode jsonNode = realObjectMapper.createObjectNode();