From c99b46e8dc5d43a179e8f7a83e6a24fd6edbe4b7 Mon Sep 17 00:00:00 2001 From: junyong Date: Thu, 19 Feb 2026 07:27:24 +0900 Subject: [PATCH 1/2] =?UTF-8?q?refactor:=20=EC=95=84=EC=9D=B4=EB=94=94?= =?UTF-8?q?=EC=96=B4=20=EB=B6=84=EC=84=9D=20=EB=B9=84=EB=8F=99=EA=B8=B0=20?= =?UTF-8?q?=EC=A0=80=EC=9E=A5=E2=80=A2=EC=BA=90=EC=8B=9C=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0=20=20=20-=20=EB=B6=84=EC=84=9D=20=EB=B9=84=EB=8F=99?= =?UTF-8?q?=EA=B8=B0=20=EC=A0=80=EC=9E=A5=EC=9A=A9=20executor=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20=20=20-=20PENDING=20=EB=B6=84=EC=84=9D=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20=EC=8B=9C=20Redis=20=EC=BA=90=EC=8B=9C=20=EC=9A=B0?= =?UTF-8?q?=EC=84=A0=20=EC=9D=91=EB=8B=B5=20=20=20-=20Redis=20=EC=BA=90?= =?UTF-8?q?=EC=8B=9C=20=EC=97=AD=EC=A7=81=EB=A0=AC=ED=99=94=20=EB=B3=B4?= =?UTF-8?q?=EC=A0=95=20=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/IdeaAnalysisController.java | 15 +- .../IdeaAnalysisRequestConverter.java | 14 +- .../IdeaAnalysisSplitRequestConverter.java | 181 ++++++++++++++ .../IdeaAnalysisSplitResponseConverter.java | 160 ++++++++++++ .../IdeaAnalysisSplitSchemaBuilder.java | 142 +++++++++++ .../service/AnalysisRedisCacheService.java | 70 ++++++ .../IdeaAnalysisBulkPersistService.java | 138 +++++++++++ .../analysis/service/IdeaAnalysisService.java | 44 +++- .../IdeaAnalysisSplitAsyncPersistService.java | 40 +++ .../service/IdeaAnalysisSplitService.java | 232 ++++++++++++++++++ .../nect/api/global/config/AsyncConfig.java | 35 +++ .../resources/prompts/idea-analysis-a.txt | 38 +++ .../resources/prompts/idea-analysis-b.txt | 51 ++++ .../resources/prompts/idea-analysis-c.txt | 61 +++++ .../openai/config/OpenAiProperties.java | 5 +- .../src/main/resources/application-client.yml | 5 +- .../entity/analysis/ProjectIdeaAnalysis.java | 18 +- .../analysis/enums/AnalysisSaveStatus.java | 14 ++ .../AnalysisImprovementPointRepository.java | 7 + .../analysis/AnalysisRoleTaskRepository.java | 7 + .../AnalysisTeamCompositionRepository.java | 7 + .../AnalysisWeeklyRoadmapRepository.java | 7 + .../src/main/resources/application-core.yml | 5 +- 23 files changed, 1276 insertions(+), 20 deletions(-) create mode 100644 nect-api/src/main/java/com/nect/api/domain/analysis/converter/IdeaAnalysisSplitRequestConverter.java create mode 100644 nect-api/src/main/java/com/nect/api/domain/analysis/converter/IdeaAnalysisSplitResponseConverter.java create mode 100644 nect-api/src/main/java/com/nect/api/domain/analysis/converter/IdeaAnalysisSplitSchemaBuilder.java create mode 100644 nect-api/src/main/java/com/nect/api/domain/analysis/service/AnalysisRedisCacheService.java create mode 100644 nect-api/src/main/java/com/nect/api/domain/analysis/service/IdeaAnalysisBulkPersistService.java create mode 100644 nect-api/src/main/java/com/nect/api/domain/analysis/service/IdeaAnalysisSplitAsyncPersistService.java create mode 100644 nect-api/src/main/java/com/nect/api/domain/analysis/service/IdeaAnalysisSplitService.java create mode 100644 nect-api/src/main/java/com/nect/api/global/config/AsyncConfig.java create mode 100644 nect-api/src/main/resources/prompts/idea-analysis-a.txt create mode 100644 nect-api/src/main/resources/prompts/idea-analysis-b.txt create mode 100644 nect-api/src/main/resources/prompts/idea-analysis-c.txt create mode 100644 nect-core/src/main/java/com/nect/core/entity/analysis/enums/AnalysisSaveStatus.java create mode 100644 nect-core/src/main/java/com/nect/core/repository/analysis/AnalysisImprovementPointRepository.java create mode 100644 nect-core/src/main/java/com/nect/core/repository/analysis/AnalysisRoleTaskRepository.java create mode 100644 nect-core/src/main/java/com/nect/core/repository/analysis/AnalysisTeamCompositionRepository.java create mode 100644 nect-core/src/main/java/com/nect/core/repository/analysis/AnalysisWeeklyRoadmapRepository.java diff --git a/nect-api/src/main/java/com/nect/api/domain/analysis/controller/IdeaAnalysisController.java b/nect-api/src/main/java/com/nect/api/domain/analysis/controller/IdeaAnalysisController.java index 7712e44c..ed0bfbd0 100644 --- a/nect-api/src/main/java/com/nect/api/domain/analysis/controller/IdeaAnalysisController.java +++ b/nect-api/src/main/java/com/nect/api/domain/analysis/controller/IdeaAnalysisController.java @@ -6,6 +6,7 @@ import com.nect.api.domain.analysis.dto.res.IdeaAnalysisResponseDto; import com.nect.api.domain.analysis.dto.res.ProjectCreateResponseDto; import com.nect.api.domain.analysis.service.IdeaAnalysisService; +import com.nect.api.domain.analysis.service.IdeaAnalysisSplitService; import com.nect.api.domain.team.project.service.ProjectService; import com.nect.api.global.response.ApiResponse; import com.nect.api.global.security.UserDetailsImpl; @@ -19,6 +20,7 @@ public class IdeaAnalysisController { private final IdeaAnalysisService ideaAnalysisService; + private final IdeaAnalysisSplitService ideaAnalysisSplitService; private final ProjectService projectService; @GetMapping @@ -44,6 +46,17 @@ public ApiResponse analyzeIdea( return ApiResponse.ok(response); } + @PostMapping("/new") + public ApiResponse newAnalyzeIdea( + @AuthenticationPrincipal UserDetailsImpl userDetails, + @RequestBody IdeaAnalysisRequestDto requestDto) { + + Long userId = userDetails.getUserId(); + IdeaAnalysisResponseDto response = ideaAnalysisSplitService.analyzeProjectIdeaSplit(userId, requestDto); + + return ApiResponse.ok(response); + } + @DeleteMapping("/{analysisId}") public ApiResponse deleteAnalysis( @AuthenticationPrincipal UserDetailsImpl userDetails, @@ -68,5 +81,3 @@ public ApiResponse createProjectFromAnalysis( } } - - diff --git a/nect-api/src/main/java/com/nect/api/domain/analysis/converter/IdeaAnalysisRequestConverter.java b/nect-api/src/main/java/com/nect/api/domain/analysis/converter/IdeaAnalysisRequestConverter.java index d62418f1..04cfa4bc 100644 --- a/nect-api/src/main/java/com/nect/api/domain/analysis/converter/IdeaAnalysisRequestConverter.java +++ b/nect-api/src/main/java/com/nect/api/domain/analysis/converter/IdeaAnalysisRequestConverter.java @@ -2,6 +2,7 @@ import com.nect.api.domain.analysis.dto.req.IdeaAnalysisRequestDto; import com.nect.api.domain.analysis.util.PromptLoader; +import com.nect.client.openai.config.OpenAiProperties; import com.nect.client.openai.dto.OpenAiResponseFormat; import com.nect.client.openai.dto.OpenAiResponseRequest; import com.nect.client.openai.dto.OpenAiResponseText; @@ -18,6 +19,7 @@ public class IdeaAnalysisRequestConverter { private final PromptLoader promptLoader; private final IdeaAnalysisSchemaBuilder ideaAnalysisSchemaBuilder; + private final OpenAiProperties openAiProperties; private static final String PROMPT_PATH = "prompts/idea-analysis.txt"; @@ -27,20 +29,16 @@ public OpenAiResponseRequest toOpenAiRequest(IdeaAnalysisRequestDto dto) { Map schema = ideaAnalysisSchemaBuilder.buildIdeaAnalysisSchema(); - OpenAiResponseFormat format = OpenAiResponseFormat.jsonSchema( - "IdeaAnalysisResponse", - schema, - true - ); + OpenAiResponseFormat format = OpenAiResponseFormat.jsonSchema("IdeaAnalysisResponse", schema, true); OpenAiResponseText text = OpenAiResponseText.withFormat(format); return OpenAiResponseRequest.builder() - .model("gpt-4o") + .model(openAiProperties.getModel()) .input(prompt) .text(text) .temperature(0.7) - .maxOutputTokens(20000) + .maxOutputTokens(openAiProperties.getMaxOutputToken()) .metadata(Map.of( "promptVersion", "v1.0", "feature", "idea-analysis" @@ -67,4 +65,4 @@ private String buildPrompt(IdeaAnalysisRequestDto dto) { return promptLoader.loadPrompt(PROMPT_PATH, variables); } -} \ No newline at end of file +} diff --git a/nect-api/src/main/java/com/nect/api/domain/analysis/converter/IdeaAnalysisSplitRequestConverter.java b/nect-api/src/main/java/com/nect/api/domain/analysis/converter/IdeaAnalysisSplitRequestConverter.java new file mode 100644 index 00000000..6872d635 --- /dev/null +++ b/nect-api/src/main/java/com/nect/api/domain/analysis/converter/IdeaAnalysisSplitRequestConverter.java @@ -0,0 +1,181 @@ +package com.nect.api.domain.analysis.converter; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.nect.api.domain.analysis.dto.req.IdeaAnalysisRequestDto; +import com.nect.api.domain.analysis.dto.res.IdeaAnalysisResponseDto; +import com.nect.api.domain.analysis.util.PromptLoader; +import com.nect.client.openai.config.OpenAiProperties; +import com.nect.client.openai.dto.OpenAiResponseFormat; +import com.nect.client.openai.dto.OpenAiResponseRequest; +import com.nect.client.openai.dto.OpenAiResponseText; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.time.LocalDate; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * 아이디어 분석을 파트별 요청으로 변환하는 컨버터입니다. + * + * 프롬프트 템플릿과 JSON 스키마를 결합하여 + * OpenAI 요청 객체를 생성합니다. + */ +@Component +@RequiredArgsConstructor +public class IdeaAnalysisSplitRequestConverter { + + private static final String PROMPT_PATH_A = "prompts/idea-analysis-a.txt"; + private static final String PROMPT_PATH_B = "prompts/idea-analysis-b.txt"; + private static final String PROMPT_PATH_C = "prompts/idea-analysis-c.txt"; + + private static final int PART_A_MAX_OUTPUT_TOKENS = 800; + private static final int PART_B_MAX_OUTPUT_TOKENS = 2000; + private static final int PART_C_MAX_OUTPUT_TOKENS = 2500; + + private final PromptLoader promptLoader; + private final IdeaAnalysisSplitSchemaBuilder splitSchemaBuilder; + private final ObjectMapper objectMapper; + private final OpenAiProperties openAiProperties; + + /** + * Part A(설명, 추천 프로젝트명, 기간) 요청을 생성합니다. + */ + public OpenAiResponseRequest toOpenAiRequestPartA(IdeaAnalysisRequestDto dto) { + String prompt = buildBasePrompt(PROMPT_PATH_A, dto, Map.of()); + + OpenAiResponseFormat format = OpenAiResponseFormat.jsonSchema( + "IdeaAnalysisPartA", + splitSchemaBuilder.buildPartASchema(), + true + ); + + return OpenAiResponseRequest.builder() + .model(openAiProperties.getModel()) + .input(prompt) + .text(OpenAiResponseText.withFormat(format)) + .temperature(0.7) + .maxOutputTokens(PART_A_MAX_OUTPUT_TOKENS) + .metadata(Map.of( + "promptVersion", "v1.0", + "feature", "idea-analysis-part-a" + )) + .build(); + } + + /** + * Part B(팀 구성, 개선점) 요청을 생성합니다. + */ + public OpenAiResponseRequest toOpenAiRequestPartB(IdeaAnalysisRequestDto dto) { + Map extra = Map.of( + "roleFields", String.join(", ", splitSchemaBuilder.getRoleFieldNames()) + ); + String prompt = buildBasePrompt(PROMPT_PATH_B, dto, extra); + + OpenAiResponseFormat format = OpenAiResponseFormat.jsonSchema( + "IdeaAnalysisPartB", + splitSchemaBuilder.buildPartBSchema(), + true + ); + + return OpenAiResponseRequest.builder() + .model(openAiProperties.getModel()) + .input(prompt) + .text(OpenAiResponseText.withFormat(format)) + .temperature(0.7) + .maxOutputTokens(PART_B_MAX_OUTPUT_TOKENS) + .metadata(Map.of( + "promptVersion", "v1.0", + "feature", "idea-analysis-part-b" + )) + .build(); + } + + /** + * Part C(주차별 로드맵) 요청을 생성합니다. + * + * 주차 범위와 팀 구성을 함께 전달합니다. + */ + public OpenAiResponseRequest toOpenAiRequestPartC( + IdeaAnalysisRequestDto dto, + int totalWeeks, + int startWeek, + int endWeek, + List teamComposition) { + + Map extra = new HashMap<>(); + extra.put("totalWeeks", String.valueOf(totalWeeks)); + extra.put("startWeek", String.valueOf(startWeek)); + extra.put("endWeek", String.valueOf(endWeek)); + extra.put("teamComposition", toTeamCompositionJson(teamComposition)); + + String prompt = buildBasePrompt(PROMPT_PATH_C, dto, extra); + + OpenAiResponseFormat format = OpenAiResponseFormat.jsonSchema( + "IdeaAnalysisPartC", + splitSchemaBuilder.buildPartCSchema(), + true + ); + + return OpenAiResponseRequest.builder() + .model(openAiProperties.getModel()) + .input(prompt) + .text(OpenAiResponseText.withFormat(format)) + .temperature(0.7) + .maxOutputTokens(PART_C_MAX_OUTPUT_TOKENS) + .metadata(Map.of( + "promptVersion", "v1.0", + "feature", "idea-analysis-part-c" + )) + .build(); + } + + /** + * 공통 프롬프트 템플릿을 로드하고 변수를 치환합니다. + */ + private String buildBasePrompt(String path, IdeaAnalysisRequestDto dto, Map extra) { + Map variables = new HashMap<>(); + variables.put("projectName", dto.getProjectName()); + variables.put("projectSummary", dto.getProjectSummary()); + variables.put("targetUsers", dto.getTargetUsers()); + variables.put("problemStatement", dto.getProblemStatement()); + variables.put("coreFeature1", dto.getCoreFeature1()); + variables.put("coreFeature2", dto.getCoreFeature2()); + variables.put("coreFeature3", dto.getCoreFeature3()); + variables.put("platform", dto.getPlatform()); + variables.put("referenceServices", dto.getReferenceServices()); + variables.put("technicalChallenges", dto.getTechnicalChallenges()); + variables.put("targetCompletionDate", + dto.getTargetCompletionDate() != null ? dto.getTargetCompletionDate().toString() : "미정"); + variables.put("today", LocalDate.now().toString()); + variables.putAll(extra); + + return promptLoader.loadPrompt(path, variables); + } + + /** + * 팀 구성 정보를 JSON 문자열로 변환합니다. + */ + private String toTeamCompositionJson(List teamComposition) { + if (teamComposition == null || teamComposition.isEmpty()) { + return "[]"; + } + List> items = teamComposition.stream() + .map(member -> { + Map item = new HashMap<>(); + item.put("role_field", member.getRoleField()); + item.put("role_field_display_name", member.getRoleFieldDisplayName()); + item.put("count", member.getRequiredCount()); + return item; + }) + .collect(Collectors.toList()); + try { + return objectMapper.writeValueAsString(items); + } catch (JsonProcessingException e) { + return "[]"; + } + } +} diff --git a/nect-api/src/main/java/com/nect/api/domain/analysis/converter/IdeaAnalysisSplitResponseConverter.java b/nect-api/src/main/java/com/nect/api/domain/analysis/converter/IdeaAnalysisSplitResponseConverter.java new file mode 100644 index 00000000..8eeb987d --- /dev/null +++ b/nect-api/src/main/java/com/nect/api/domain/analysis/converter/IdeaAnalysisSplitResponseConverter.java @@ -0,0 +1,160 @@ +package com.nect.api.domain.analysis.converter; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.nect.api.domain.analysis.dto.res.IdeaAnalysisResponseDto; +import com.nect.api.domain.analysis.dto.res.IdeaAnalysisResponseDto.*; +import com.nect.client.openai.dto.OpenAiResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.ArrayList; +import java.util.List; + +/** + * 파트별 OpenAI 응답을 분석 DTO로 변환하는 컨버터입니다. + * + * Part A/B/C 응답을 각각 파싱해 필요한 필드만 추출합니다. + */ +@Component +@RequiredArgsConstructor +public class IdeaAnalysisSplitResponseConverter { + + private final ObjectMapper objectMapper; + + /** + * Part A 응답을 파싱합니다. + */ + public IdeaAnalysisResponseDto toPartAResponse(OpenAiResponse openAiResponse) { + try { + String jsonContent = openAiResponse.getFirstOutputText(); + JsonNode root = objectMapper.readTree(jsonContent); + + return IdeaAnalysisResponseDto.builder() + .recommendedProjectNames(parseRecommendedProjectNames(root)) + .description(root.get("description").asText()) + .projectDuration(parseProjectDuration(root)) + .build(); + } catch (Exception e) { + throw new RuntimeException("OpenAI 응답 파싱 실패 (Part A)", e); + } + } + + /** + * Part B 응답을 파싱합니다. + */ + public IdeaAnalysisResponseDto toPartBResponse(OpenAiResponse openAiResponse) { + try { + String jsonContent = openAiResponse.getFirstOutputText(); + JsonNode root = objectMapper.readTree(jsonContent); + + return IdeaAnalysisResponseDto.builder() + .teamComposition(parseTeamComposition(root)) + .improvementPoints(parseImprovementPoints(root)) + .build(); + } catch (Exception e) { + throw new RuntimeException("OpenAI 응답 파싱 실패 (Part B)", e); + } + } + + /** + * Part C 응답을 파싱합니다. + */ + public IdeaAnalysisResponseDto toPartCResponse(OpenAiResponse openAiResponse) { + try { + String jsonContent = openAiResponse.getFirstOutputText(); + JsonNode root = objectMapper.readTree(jsonContent); + + return IdeaAnalysisResponseDto.builder() + .weeklyRoadmap(parseWeeklyRoadmap(root)) + .build(); + } catch (Exception e) { + throw new RuntimeException("OpenAI 응답 파싱 실패 (Part C)", e); + } + } + + private List parseRecommendedProjectNames(JsonNode root) { + List names = new ArrayList<>(); + JsonNode namesNode = root.get("recommended_project_names"); + if (namesNode != null && namesNode.isArray()) { + namesNode.forEach(node -> names.add(node.asText())); + } + return names; + } + + private ProjectDuration parseProjectDuration(JsonNode root) { + JsonNode durationNode = root.get("project_duration"); + if (durationNode != null) { + int totalWeeks = durationNode.get("total_weeks").asInt(); + return ProjectDuration.builder() + .totalWeeks(totalWeeks) + .build(); + } + return null; + } + + private List parseTeamComposition(JsonNode root) { + List teamMembers = new ArrayList<>(); + JsonNode teamNode = root.get("team_composition"); + + if (teamNode != null && teamNode.isArray()) { + teamNode.forEach(node -> { + TeamMember member = TeamMember.builder() + .roleField(node.get("role_field").asText()) + .roleFieldDisplayName(node.get("role_field_display_name").asText()) + .requiredCount(node.get("count").asInt()) + .build(); + teamMembers.add(member); + }); + } + return teamMembers; + } + + private List parseImprovementPoints(JsonNode root) { + List points = new ArrayList<>(); + JsonNode pointsNode = root.get("improvement_points"); + + if (pointsNode != null && pointsNode.isArray()) { + pointsNode.forEach(node -> { + ImprovementPoint point = ImprovementPoint.builder() + .order(node.get("order").asInt()) + .title(node.get("title").asText()) + .description(node.get("description").asText()) + .build(); + points.add(point); + }); + } + return points; + } + + private List parseWeeklyRoadmap(JsonNode root) { + List roadmaps = new ArrayList<>(); + JsonNode roadmapNode = root.get("weekly_roadmap"); + + if (roadmapNode != null && roadmapNode.isArray()) { + roadmapNode.forEach(weekNode -> { + List roleTasks = new ArrayList<>(); + JsonNode tasksNode = weekNode.get("role_tasks"); + + if (tasksNode != null && tasksNode.isArray()) { + tasksNode.forEach(taskNode -> { + RoleTask roleTask = RoleTask.builder() + .roleField(taskNode.get("role_field").asText()) + .roleFieldDisplayName(taskNode.get("role_field_display_name").asText()) + .tasks(taskNode.get("tasks").asText()) + .build(); + roleTasks.add(roleTask); + }); + } + + WeeklyRoadmap roadmap = WeeklyRoadmap.builder() + .weekNumber(weekNode.get("week_number").asInt()) + .weekTitle(weekNode.get("week_title").asText()) + .roleTasks(roleTasks) + .build(); + roadmaps.add(roadmap); + }); + } + return roadmaps; + } +} diff --git a/nect-api/src/main/java/com/nect/api/domain/analysis/converter/IdeaAnalysisSplitSchemaBuilder.java b/nect-api/src/main/java/com/nect/api/domain/analysis/converter/IdeaAnalysisSplitSchemaBuilder.java new file mode 100644 index 00000000..e3ec95ed --- /dev/null +++ b/nect-api/src/main/java/com/nect/api/domain/analysis/converter/IdeaAnalysisSplitSchemaBuilder.java @@ -0,0 +1,142 @@ +package com.nect.api.domain.analysis.converter; + +import com.nect.core.entity.user.enums.Role; +import com.nect.core.entity.user.enums.RoleField; +import org.springframework.stereotype.Component; + +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * 아이디어 분석 파트별 JSON 스키마를 생성하는 빌더입니다. + * + * OpenAI의 JSON Schema 응답 형식에 맞게 + * 파트별 구조와 필수 필드를 정의합니다. + */ +@Component +public class IdeaAnalysisSplitSchemaBuilder { + + /** + * Part A 스키마를 생성합니다. + */ + public Map buildPartASchema() { + return Map.of( + "type", "object", + "properties", Map.of( + "description", Map.of("type", "string"), + "recommended_project_names", Map.of( + "type", "array", + "items", Map.of("type", "string") + ), + "project_duration", Map.of( + "type", "object", + "properties", Map.of( + "total_weeks", Map.of("type", "integer") + ), + "required", List.of("total_weeks"), + "additionalProperties", false + ) + ), + "required", List.of("description", "recommended_project_names", "project_duration"), + "additionalProperties", false + ); + } + + /** + * Part B 스키마를 생성합니다. + */ + public Map buildPartBSchema() { + List allRoleFields = getRoleFieldNames(); + return Map.of( + "type", "object", + "properties", Map.of( + "team_composition", Map.of( + "type", "array", + "items", Map.of( + "type", "object", + "properties", Map.of( + "role_field", Map.of( + "type", "string", + "enum", allRoleFields + ), + "role_field_display_name", Map.of("type", "string"), + "count", Map.of("type", "integer") + ), + "required", List.of("role_field", "role_field_display_name", "count"), + "additionalProperties", false + ) + ), + "improvement_points", Map.of( + "type", "array", + "items", Map.of( + "type", "object", + "properties", Map.of( + "order", Map.of("type", "integer"), + "title", Map.of("type", "string"), + "description", Map.of("type", "string") + ), + "required", List.of("order", "title", "description"), + "additionalProperties", false + ) + ) + ), + "required", List.of("team_composition", "improvement_points"), + "additionalProperties", false + ); + } + + /** + * Part C 스키마를 생성합니다. + */ + public Map buildPartCSchema() { + List allRoleFields = getRoleFieldNames(); + return Map.of( + "type", "object", + "properties", Map.of( + "weekly_roadmap", Map.of( + "type", "array", + "items", Map.of( + "type", "object", + "properties", Map.of( + "week_number", Map.of("type", "integer"), + "week_title", Map.of("type", "string"), + "role_tasks", Map.of( + "type", "array", + "items", Map.of( + "type", "object", + "properties", Map.of( + "role_field", Map.of( + "type", "string", + "enum", allRoleFields + ), + "role_field_display_name", Map.of("type", "string"), + "tasks", Map.of("type", "string") + ), + "required", List.of("role_field", "role_field_display_name", "tasks"), + "additionalProperties", false + ) + ) + ), + "required", List.of("week_number", "week_title", "role_tasks"), + "additionalProperties", false + ) + ) + ), + "required", List.of("weekly_roadmap"), + "additionalProperties", false + ); + } + + /** + * 요청에 허용되는 RoleField 목록을 반환합니다. + */ + public List getRoleFieldNames() { + return Arrays.stream(RoleField.values()) + .filter(rf -> rf != RoleField.CUSTOM) + .filter(rf -> rf.getRole() != Role.OTHER) + .map(Enum::name) + .collect(Collectors.toList()); + } +} diff --git a/nect-api/src/main/java/com/nect/api/domain/analysis/service/AnalysisRedisCacheService.java b/nect-api/src/main/java/com/nect/api/domain/analysis/service/AnalysisRedisCacheService.java new file mode 100644 index 00000000..83603cae --- /dev/null +++ b/nect-api/src/main/java/com/nect/api/domain/analysis/service/AnalysisRedisCacheService.java @@ -0,0 +1,70 @@ +package com.nect.api.domain.analysis.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.nect.api.domain.analysis.dto.res.IdeaAnalysisResponseDto; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Service; + +import java.time.Duration; + +/** + * 아이디어 분석 결과를 Redis에 캐싱하는 서비스입니다. + * + * DB 저장이 비동기로 진행되는 동안, + * 분석 결과를 임시로 보관/조회하는 용도로 사용합니다. + */ +@Service +@Slf4j +@RequiredArgsConstructor +public class AnalysisRedisCacheService { + + private static final Duration CACHE_TTL = Duration.ofMinutes(30); + + private final ObjectMapper objectMapper; + private final RedisTemplate redisTemplate; + + /** + * 분석 결과를 Redis에 저장합니다. + * + * 직렬화 실패가 있어도 흐름을 막지 않도록 예외는 로그만 남깁니다. + */ + public void cacheResponse(Long analysisId, IdeaAnalysisResponseDto response) { + try { + redisTemplate.opsForValue().set(payloadKey(analysisId), response, CACHE_TTL); + log.info("analysis async persist done analysisId={}", analysisId); + } catch (Exception e) { + log.warn("analysis cache serialization failed id={}", analysisId, e); + } + } + + /** + * Redis에서 분석 결과를 조회합니다. + * + * - DTO로 그대로 역직렬화된 경우: 캐스팅 + * - Map으로 역직렬화된 경우: ObjectMapper로 변환 + */ + public IdeaAnalysisResponseDto getCachedResponse(Long analysisId) { + Object payload = redisTemplate.opsForValue().get(payloadKey(analysisId)); + if (payload instanceof IdeaAnalysisResponseDto) { + return (IdeaAnalysisResponseDto) payload; + } + if (payload != null) { + try { + return objectMapper.convertValue(payload, IdeaAnalysisResponseDto.class); + } catch (IllegalArgumentException e) { + log.warn("analysis cache convert failed id={} type={}", analysisId, payload.getClass().getName(), e); + } + } + return null; + } + + public void evict(Long analysisId) { + redisTemplate.delete(payloadKey(analysisId)); + } + + private String payloadKey(Long analysisId) { + return "analysis:" + analysisId + ":payload"; + } +} diff --git a/nect-api/src/main/java/com/nect/api/domain/analysis/service/IdeaAnalysisBulkPersistService.java b/nect-api/src/main/java/com/nect/api/domain/analysis/service/IdeaAnalysisBulkPersistService.java new file mode 100644 index 00000000..20812f74 --- /dev/null +++ b/nect-api/src/main/java/com/nect/api/domain/analysis/service/IdeaAnalysisBulkPersistService.java @@ -0,0 +1,138 @@ +package com.nect.api.domain.analysis.service; + +import com.nect.api.domain.analysis.dto.res.IdeaAnalysisResponseDto; +import com.nect.core.entity.analysis.*; +import com.nect.core.entity.user.enums.RoleField; +import com.nect.core.repository.analysis.*; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import jakarta.persistence.EntityManager; +import java.util.ArrayList; +import java.util.List; + +/** + * 아이디어 분석 결과를 배치로 저장/갱신하는 전용 레포지토리입니다. + * + * 분석 결과가 대량의 하위 엔티티(팀 구성, 개선점, 주차별 로드맵, 역할별 태스크)를 + * 포함하므로, 저장 성능을 위해 batch 단위로 flush/clear를 수행합니다. + * + * Repository임에도 불구하고 api 모듈에 둔 이유 + * • IdeaAnalysisBulkRepository가 DTO(IdeaAnalysisResponseDto)를 직접 받음 → core에 두면 계층 의존이 역전됨 + * • 여러 Repository 조합 + batch flush/clear 같은 애플리케이션 레벨 orchestration 성격이 강함 + * + */ +@Repository +@Slf4j +@RequiredArgsConstructor +public class IdeaAnalysisBulkPersistService { + + private static final int BATCH_SIZE = 50; + + private final ProjectIdeaAnalysisRepository projectIdeaAnalysisRepository; + private final AnalysisTeamCompositionRepository teamCompositionRepository; + private final AnalysisImprovementPointRepository improvementPointRepository; + private final AnalysisWeeklyRoadmapRepository weeklyRoadmapRepository; + private final AnalysisRoleTaskRepository roleTaskRepository; + private final EntityManager entityManager; + + /** + * 기존 분석 stub에 상세 정보를 일괄 저장합니다. + * + * - 메인 분석 엔티티 업데이트 + * - 하위 엔티티들을 batch로 저장 + */ + public ProjectIdeaAnalysis updateAnalysisWithDetails(Long analysisId, IdeaAnalysisResponseDto response) { + ProjectIdeaAnalysis analysis = projectIdeaAnalysisRepository.findById(analysisId) + .orElseThrow(() -> new IllegalArgumentException("analysis not found id=" + analysisId)); + + List projectNames = response.getRecommendedProjectNames(); + analysis.updateDetails( + response.getDescription(), + projectNames.get(0), + projectNames.size() > 1 ? projectNames.get(1) : null, + projectNames.size() > 2 ? projectNames.get(2) : null, + response.getProjectDuration().getStartDate(), + response.getProjectDuration().getEndDate(), + response.getProjectDuration().getTotalWeeks() + ); + projectIdeaAnalysisRepository.save(analysis); + + List teamComps = new ArrayList<>(); + for (IdeaAnalysisResponseDto.TeamMember member : response.getTeamComposition()) { + AnalysisTeamComposition teamComp = AnalysisTeamComposition.builder() + .roleField(RoleField.valueOf(member.getRoleField())) + .requiredCount(member.getRequiredCount()) + .build(); + teamComp.setAnalysis(analysis); + teamComps.add(teamComp); + } + + List improvementPoints = new ArrayList<>(); + for (IdeaAnalysisResponseDto.ImprovementPoint point : response.getImprovementPoints()) { + AnalysisImprovementPoint improvementPoint = AnalysisImprovementPoint.builder() + .pointOrder(point.getOrder()) + .title(point.getTitle()) + .description(point.getDescription()) + .build(); + improvementPoint.setAnalysis(analysis); + improvementPoints.add(improvementPoint); + } + + List weeklyRoadmaps = new ArrayList<>(); + List roleTasks = new ArrayList<>(); + for (IdeaAnalysisResponseDto.WeeklyRoadmap roadmap : response.getWeeklyRoadmap()) { + AnalysisWeeklyRoadmap weeklyRoadmap = AnalysisWeeklyRoadmap.builder() + .weekNumber(roadmap.getWeekNumber()) + .weekTitle(roadmap.getWeekTitle()) + .weekStartDate(roadmap.getWeekStartDate()) + .weekEndDate(roadmap.getWeekEndDate()) + .build(); + weeklyRoadmap.setAnalysis(analysis); + weeklyRoadmaps.add(weeklyRoadmap); + + for (IdeaAnalysisResponseDto.RoleTask roleTask : roadmap.getRoleTasks()) { + AnalysisRoleTask task = AnalysisRoleTask.builder() + .roleField(RoleField.valueOf(roleTask.getRoleField())) + .tasks(roleTask.getTasks()) + .build(); + task.setWeeklyRoadmap(weeklyRoadmap); + roleTasks.add(task); + } + } + + batchSave("teamComposition", teamCompositionRepository, teamComps, true); + batchSave("improvementPoint", improvementPointRepository, improvementPoints, true); + batchSave("weeklyRoadmap", weeklyRoadmapRepository, weeklyRoadmaps, false); + weeklyRoadmapRepository.flush(); + + batchSave("roleTask", roleTaskRepository, roleTasks, true); + roleTaskRepository.flush(); + + return analysis; + } + + /** + * 하위 엔티티를 batch 단위로 저장합니다. + * + * flush/clear 정책을 통해 메모리 사용량과 영속성 컨텍스트 크기를 제어합니다. + */ + private void batchSave(String label, JpaRepository repository, List items, boolean clearAfterBatch) { + if (items == null || items.isEmpty()) { + return; + } + for (int i = 0; i < items.size(); i += BATCH_SIZE) { + int end = Math.min(i + BATCH_SIZE, items.size()); + long start = System.nanoTime(); + repository.saveAll(items.subList(i, end)); + repository.flush(); + long ms = (System.nanoTime() - start) / 1_000_000L; + log.info("ideaAnalysisSplit batchSave label={} range={}~{} size={} ms={}", label, i + 1, end, end - i, ms); + if (clearAfterBatch) { + entityManager.clear(); + } + } + } +} diff --git a/nect-api/src/main/java/com/nect/api/domain/analysis/service/IdeaAnalysisService.java b/nect-api/src/main/java/com/nect/api/domain/analysis/service/IdeaAnalysisService.java index d319ab82..01e3b60f 100644 --- a/nect-api/src/main/java/com/nect/api/domain/analysis/service/IdeaAnalysisService.java +++ b/nect-api/src/main/java/com/nect/api/domain/analysis/service/IdeaAnalysisService.java @@ -13,6 +13,7 @@ import com.nect.client.openai.dto.OpenAiResponse; import com.nect.client.openai.dto.OpenAiResponseRequest; import com.nect.core.entity.analysis.*; +import com.nect.core.entity.analysis.enums.AnalysisSaveStatus; import com.nect.core.entity.user.enums.RoleField; import com.nect.core.repository.analysis.ProjectIdeaAnalysisRepository; import lombok.RequiredArgsConstructor; @@ -28,7 +29,12 @@ import java.util.Set; import java.util.stream.Collectors; - +/** + * 아이디어 분석 결과 생성 및 조회를 담당하는 서비스입니다. + * + * - 분석 결과 생성 및 DB 저장 + * - 분석 페이지 조회 (pending 상태는 Redis 캐시 우선) + */ @Service @RequiredArgsConstructor public class IdeaAnalysisService { @@ -37,18 +43,20 @@ public class IdeaAnalysisService { private final IdeaAnalysisRequestConverter requestConverter; private final IdeaAnalysisResponseConverter responseConverter; private final ProjectIdeaAnalysisRepository projectIdeaAnalysisRepository; + private final AnalysisRedisCacheService analysisRedisCacheService; private final ObjectMapper objectMapper; + /** + * 아이디어 분석을 수행하고 결과를 DB에 저장합니다. + */ public IdeaAnalysisResponseDto analyzeProjectIdea(Long userId, IdeaAnalysisRequestDto requestDto) { - long analysisCount = projectIdeaAnalysisRepository.countByUserId(userId); if (analysisCount >= 2) { throw new IdeaAnalysisException(IdeaAnalysisErrorCode.TOO_MANY_ANALYSIS, "아이디어 분석은 인당 최대 2개까지만 가능합니다."); } try { - OpenAiResponseRequest openAiRequest = requestConverter.toOpenAiRequest(requestDto); OpenAiResponse openAiResponse = openAiClient.createResponse(openAiRequest); IdeaAnalysisResponseDto response = responseConverter.toIdeaAnalysisResponse(openAiResponse); @@ -196,6 +204,12 @@ private ProjectIdeaAnalysis createAnalysisEntity(Long userId, IdeaAnalysisRespon return analysis; } + /** + * 최신 분석 결과 페이지를 조회합니다. + * + * - PENDING 상태라면 Redis 캐시에서 우선 조회 + * - 캐시가 없을 경우 최소 정보만 반환 + */ @Transactional(readOnly = true) public IdeaAnalysisPageResponseDto getAnalysisPage(Long userId, int page) { @@ -210,6 +224,20 @@ public IdeaAnalysisPageResponseDto getAnalysisPage(Long userId, int page) { ProjectIdeaAnalysis analysis = analysisPage.getContent().get(0); + // 아직 DB에 저장되지 않았다면 캐시에서 꺼내서 반환 + if (isPending(analysis)) { + IdeaAnalysisResponseDto cached = analysisRedisCacheService.getCachedResponse(analysis.getId()); + if (cached != null) { + return IdeaAnalysisEntityConverter.toPageResponseDto(analysisPage, cached); + } + IdeaAnalysisResponseDto pendingResponse = IdeaAnalysisResponseDto.builder() + .analysisId(analysis.getId()) + .description(analysis.getDescription()) + .recommendedProjectNames(analysis.getRecommendedProjectNames()) + .build(); + return IdeaAnalysisEntityConverter.toPageResponseDto(analysisPage, pendingResponse); + } + ProjectIdeaAnalysis detailAnalysis = projectIdeaAnalysisRepository .findByIdAndUserIdWithDetails(analysis.getId(), userId) .orElseThrow(() -> new IdeaAnalysisException( @@ -224,6 +252,14 @@ public IdeaAnalysisPageResponseDto getAnalysisPage(Long userId, int page) { } + /** + * 분석 결과가 비동기 저장 대기 상태인지 확인합니다. + */ + private boolean isPending(ProjectIdeaAnalysis analysis) { + return AnalysisSaveStatus.PENDING.getStatus().equals(analysis.getDescription()) + && AnalysisSaveStatus.PENDING.getStatus().equals(analysis.getRecommendedProjectName1()); + } + @Transactional @@ -249,4 +285,4 @@ public void deleteAnalysis(Long userId, Long analysisId) { projectIdeaAnalysisRepository.delete(analysis); } -} \ No newline at end of file +} diff --git a/nect-api/src/main/java/com/nect/api/domain/analysis/service/IdeaAnalysisSplitAsyncPersistService.java b/nect-api/src/main/java/com/nect/api/domain/analysis/service/IdeaAnalysisSplitAsyncPersistService.java new file mode 100644 index 00000000..5009e503 --- /dev/null +++ b/nect-api/src/main/java/com/nect/api/domain/analysis/service/IdeaAnalysisSplitAsyncPersistService.java @@ -0,0 +1,40 @@ +package com.nect.api.domain.analysis.service; + +import com.nect.api.domain.analysis.dto.res.IdeaAnalysisResponseDto; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * 분할 분석 결과를 비동기로 DB에 저장하는 서비스입니다. + * + * Redis 캐시에 임시 저장된 분석 결과를 읽어와 + * IdeaAnalysisBulkRepository를 통해 일괄 저장합니다. + */ +@Service +@Slf4j +@RequiredArgsConstructor +public class IdeaAnalysisSplitAsyncPersistService { + + private final AnalysisRedisCacheService analysisRedisCacheService; + private final IdeaAnalysisBulkPersistService ideaAnalysisBulkPersistService; + + /** + * Redis 캐시에서 분석 결과를 읽어와 비동기로 저장합니다. + */ + @Async("analysisAsyncSaveExecutor") + @Transactional + public void persistFromCacheAsync(Long analysisId) { + IdeaAnalysisResponseDto cached = analysisRedisCacheService.getCachedResponse(analysisId); + if (cached == null) { + return; + } + try { + ideaAnalysisBulkPersistService.updateAnalysisWithDetails(analysisId, cached); + } catch (Exception e) { + log.warn("analysis async persist failed id={}", analysisId, e); + } + } +} diff --git a/nect-api/src/main/java/com/nect/api/domain/analysis/service/IdeaAnalysisSplitService.java b/nect-api/src/main/java/com/nect/api/domain/analysis/service/IdeaAnalysisSplitService.java new file mode 100644 index 00000000..df69b948 --- /dev/null +++ b/nect-api/src/main/java/com/nect/api/domain/analysis/service/IdeaAnalysisSplitService.java @@ -0,0 +1,232 @@ +package com.nect.api.domain.analysis.service; + +import com.nect.api.domain.analysis.code.enums.IdeaAnalysisErrorCode; +import com.nect.api.domain.analysis.converter.IdeaAnalysisSplitRequestConverter; +import com.nect.api.domain.analysis.converter.IdeaAnalysisSplitResponseConverter; +import com.nect.api.domain.analysis.dto.req.IdeaAnalysisRequestDto; +import com.nect.api.domain.analysis.dto.res.IdeaAnalysisResponseDto; +import com.nect.api.domain.analysis.exception.IdeaAnalysisException; +import com.nect.client.openai.OpenAiClient; +import com.nect.client.openai.dto.OpenAiResponse; +import com.nect.core.entity.analysis.ProjectIdeaAnalysis; +import com.nect.core.entity.analysis.enums.AnalysisSaveStatus; +import com.nect.core.repository.analysis.ProjectIdeaAnalysisRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.stream.Collectors; + +/** + * 분석 결과를 파트별로 생성해 병렬 처리하는 서비스입니다. + * + * - Part A/B/C 분리 호출 + * - 결과 병합 후 응답 반환 + * - 비동기 저장을 위한 Redis 캐시 적재 및 stub 생성 + */ +@Service +@RequiredArgsConstructor +public class IdeaAnalysisSplitService { + + private static final int WEEK_CHUNK_SIZE = 4; + + private final OpenAiClient openAiClient; + private final IdeaAnalysisSplitRequestConverter requestConverter; + private final IdeaAnalysisSplitResponseConverter responseConverter; + private final ProjectIdeaAnalysisRepository projectIdeaAnalysisRepository; + private final IdeaAnalysisBulkPersistService ideaAnalysisBulkPersistService; + private final AnalysisRedisCacheService analysisRedisCacheService; + private final IdeaAnalysisSplitAsyncPersistService asyncPersistService; + + /** + * 아이디어 분석을 파트 분할 방식으로 수행합니다. + * + * 응답은 즉시 반환하고, 저장은 Redis 캐시 기반으로 비동기 처리됩니다. + */ + @Transactional + public IdeaAnalysisResponseDto analyzeProjectIdeaSplit(Long userId, IdeaAnalysisRequestDto requestDto) { + long analysisCount = projectIdeaAnalysisRepository.countByUserId(userId); + if (analysisCount >= 2) { + throw new IdeaAnalysisException(IdeaAnalysisErrorCode.TOO_MANY_ANALYSIS, "아이디어 분석은 인당 최대 2개까지만 가능합니다."); + } + + try { + CompletableFuture partAFuture = CompletableFuture.supplyAsync(() -> { + OpenAiResponse response = openAiClient.createResponse(requestConverter.toOpenAiRequestPartA(requestDto)); + return responseConverter.toPartAResponse(response); + }); + + CompletableFuture partBFuture = CompletableFuture.supplyAsync(() -> { + OpenAiResponse response = openAiClient.createResponse(requestConverter.toOpenAiRequestPartB(requestDto)); + return responseConverter.toPartBResponse(response); + }); + + CompletableFuture partCFuture = partAFuture.thenCombine(partBFuture, SplitDeps::new) + .thenApplyAsync(deps -> { + Integer totalWeeks = deps.partA.getProjectDuration() != null + ? deps.partA.getProjectDuration().getTotalWeeks() + : null; + if (totalWeeks == null) { + throw new IdeaAnalysisException(IdeaAnalysisErrorCode.ANALYSIS_FAILED, "total_weeks 파싱에 실패했습니다."); + } + if (deps.partB.getTeamComposition() == null || deps.partB.getTeamComposition().isEmpty()) { + throw new IdeaAnalysisException(IdeaAnalysisErrorCode.ANALYSIS_FAILED, "team_composition 파싱에 실패했습니다."); + } + List> chunkFutures = buildPartCFutures( + requestDto, + totalWeeks, + deps.partB.getTeamComposition(), + userId + ); + CompletableFuture.allOf(chunkFutures.toArray(new CompletableFuture[0])).join(); + List merged = chunkFutures.stream() + .map(CompletableFuture::join) + .flatMap(part -> part.getWeeklyRoadmap().stream()) + .sorted((a, b) -> Integer.compare(a.getWeekNumber(), b.getWeekNumber())) + .toList(); + IdeaAnalysisResponseDto result = IdeaAnalysisResponseDto.builder() + .weeklyRoadmap(merged) + .build(); + return result; + }); + + IdeaAnalysisResponseDto partA = partAFuture.join(); + IdeaAnalysisResponseDto partB = partBFuture.join(); + IdeaAnalysisResponseDto partC = partCFuture.join(); + + IdeaAnalysisResponseDto response = IdeaAnalysisResponseDto.builder() + .description(partA.getDescription()) + .recommendedProjectNames(partA.getRecommendedProjectNames()) + .projectDuration(partA.getProjectDuration()) + .teamComposition(partB.getTeamComposition()) + .improvementPoints(partB.getImprovementPoints()) + .weeklyRoadmap(partC.getWeeklyRoadmap()) + .build(); + + finalizeAndPersist(userId, response); + Long analysisId = createAnalysisStub(userId); + response.setAnalysisId(analysisId); + analysisRedisCacheService.cacheResponse(analysisId, response); + asyncPersistService.persistFromCacheAsync(analysisId); + return response; + } catch (CompletionException e) { + Throwable cause = e.getCause() != null ? e.getCause() : e; + throw new IdeaAnalysisException(IdeaAnalysisErrorCode.ANALYSIS_FAILED, "AI 분석 중 오류가 발생했습니다.", cause); + } catch (Exception e) { + throw new IdeaAnalysisException(IdeaAnalysisErrorCode.ANALYSIS_FAILED, "AI 분석 중 오류가 발생했습니다.", e); + } + } + + private void finalizeAndPersist(Long userId, IdeaAnalysisResponseDto response) { + LocalDate startDate = LocalDate.now(); + int totalWeeks = response.getProjectDuration().getTotalWeeks(); + LocalDate endDate = startDate.plusWeeks(totalWeeks).minusDays(1); + + response.getProjectDuration().setStartDate(startDate); + response.getProjectDuration().setEndDate(endDate); + response.getProjectDuration().setDisplayText( + totalWeeks + "주 (" + startDate.format(DateTimeFormatter.ofPattern("yyyy-MM-dd")) + + " ~ " + + endDate.format(DateTimeFormatter.ofPattern("yyyy-MM-dd")) + + ")" + ); + + calculateWeeklyDates(response.getWeeklyRoadmap(), startDate); + validateRoleFieldConsistency(response); + + // main persistence happens asynchronously after response + } + + /** + * 비동기 저장 전용 PENDING stub을 생성합니다. + */ + private Long createAnalysisStub(Long userId) { + ProjectIdeaAnalysis stub = ProjectIdeaAnalysis.builder() + .userId(userId) + .description(AnalysisSaveStatus.PENDING.getStatus()) + .recommendedProjectName1("PENDING") + .build(); + return projectIdeaAnalysisRepository.save(stub).getId(); + } + + private List> buildPartCFutures( + IdeaAnalysisRequestDto requestDto, + int totalWeeks, + List teamComposition, + Long userId) { + + List> futures = new ArrayList<>(); + int start = 1; + while (start <= totalWeeks) { + int end = Math.min(start + WEEK_CHUNK_SIZE - 1, totalWeeks); + int chunkStart = start; + int chunkEnd = end; + futures.add(CompletableFuture.supplyAsync(() -> { + OpenAiResponse response = openAiClient.createResponse( + requestConverter.toOpenAiRequestPartC(requestDto, totalWeeks, chunkStart, chunkEnd, teamComposition)); + return responseConverter.toPartCResponse(response); + })); + start = end + 1; + } + return futures; + } + + private void calculateWeeklyDates(List roadmaps, LocalDate projectStartDate) { + for (IdeaAnalysisResponseDto.WeeklyRoadmap roadmap : roadmaps) { + int weekNumber = roadmap.getWeekNumber(); + LocalDate weekStart = projectStartDate.plusWeeks(weekNumber - 1); + LocalDate weekEnd = weekStart.plusDays(6); + + roadmap.setWeekStartDate(weekStart); + roadmap.setWeekEndDate(weekEnd); + roadmap.setWeekPeriod( + weekStart.format(DateTimeFormatter.ofPattern("yyyy-MM-dd")) + + " ~ " + + weekEnd.format(DateTimeFormatter.ofPattern("yyyy-MM-dd")) + ); + } + } + + private void validateRoleFieldConsistency(IdeaAnalysisResponseDto response) { + Set teamRoleFields = response.getTeamComposition().stream() + .map(IdeaAnalysisResponseDto.TeamMember::getRoleField) + .collect(Collectors.toSet()); + + for (IdeaAnalysisResponseDto.WeeklyRoadmap roadmap : response.getWeeklyRoadmap()) { + Set weekRoleFields = roadmap.getRoleTasks().stream() + .map(IdeaAnalysisResponseDto.RoleTask::getRoleField) + .collect(Collectors.toSet()); + + if (!teamRoleFields.equals(weekRoleFields)) { + throw new IdeaAnalysisException( + IdeaAnalysisErrorCode.ANALYSIS_FAILED, + String.format( + "%d주차의 역할 구성이 팀 구성과 일치하지 않습니다. " + + "팀 구성: %s, %d주차 구성: %s", + roadmap.getWeekNumber(), + teamRoleFields, + roadmap.getWeekNumber(), + weekRoleFields + ) + ); + } + } + } + + private static class SplitDeps { + private final IdeaAnalysisResponseDto partA; + private final IdeaAnalysisResponseDto partB; + + private SplitDeps(IdeaAnalysisResponseDto partA, IdeaAnalysisResponseDto partB) { + this.partA = partA; + this.partB = partB; + } + } +} diff --git a/nect-api/src/main/java/com/nect/api/global/config/AsyncConfig.java b/nect-api/src/main/java/com/nect/api/global/config/AsyncConfig.java new file mode 100644 index 00000000..5a14888b --- /dev/null +++ b/nect-api/src/main/java/com/nect/api/global/config/AsyncConfig.java @@ -0,0 +1,35 @@ +package com.nect.api.global.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.task.TaskExecutor; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; + +/** + * 비동기 실행용 TaskExecutor를 구성합니다. + * + * @Async 기반의 비동기 작업이 + * 예측 가능한 스레드 풀에서 실행되도록 전용 executor를 제공합니다. + * (기본 executor 탐색 경고를 방지하고, 실행 정책을 명확히 합니다.) + */ +@Configuration +public class AsyncConfig { + + /** + * 분석 결과 비동기 저장 전용 executor. + * + * - core/max/queue를 명시해 과도한 스레드 생성 방지 + * - 스레드 이름 prefix로 로그 식별성 확보 + */ + @Bean(name = "analysisAsyncSaveExecutor") + public TaskExecutor analysisAsyncSaveExecutor() { + int cores = Runtime.getRuntime().availableProcessors(); + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(Math.max(2, cores)); + executor.setMaxPoolSize(Math.max(4, cores * 2)); + executor.setQueueCapacity(200); + executor.setThreadNamePrefix("nect-async-"); + executor.initialize(); + return executor; + } +} diff --git a/nect-api/src/main/resources/prompts/idea-analysis-a.txt b/nect-api/src/main/resources/prompts/idea-analysis-a.txt new file mode 100644 index 00000000..5e7b0991 --- /dev/null +++ b/nect-api/src/main/resources/prompts/idea-analysis-a.txt @@ -0,0 +1,38 @@ +다음 프로젝트 정보를 분석하여 아래 형식에 맞게 응답하세요. + +## 프로젝트 정보 +- 프로젝트명: {{projectName}} +- 프로젝트 요약: {{projectSummary}} +- 타겟 사용자: {{targetUsers}} +- 해결할 문제: {{problemStatement}} +- 핵심 기능 1: {{coreFeature1}} +- 핵심 기능 2: {{coreFeature2}} +- 핵심 기능 3: {{coreFeature3}} +- 플랫폼: {{platform}} +- 레퍼런스 서비스: {{referenceServices}} +- 기술적 도전: {{technicalChallenges}} +- 목표 완료일: {{targetCompletionDate}} + +--- +## 분석 규칙 +- 오늘 날짜는 {{today}}입니다. +- total_weeks는 반드시 8주 이상 12주 이하로 설정하세요. + +# 응답 형식 +{ + "description": "<아이디어 분석을 바탕으로 이 프로젝트의 핵심 가치, 목표 및 기대효과를 전문적인 어조로 기술 (2문장)>", + "recommended_project_names": [ + "프로젝트의 핵심 가치와 목적을 반영한 브랜드명", + "타겟 사용자와 서비스 특성을 담은 브랜드명", + "직관적이고 기억하기 쉬운 브랜드명" + ], + "project_duration": { + "total_weeks": <실제 필요한 주차 수> + } +} + +**필수 규칙**: +- role_field 관련 필드는 출력하지 마세요 +- "이름1", "제목1", "..." 같은 플레이스홀더를 절대 사용하지 마세요 +- 모든 내용은 프로젝트 정보를 기반으로 구체적으로 작성 +- **모든 텍스트 필드는 반드시 한글로 작성** diff --git a/nect-api/src/main/resources/prompts/idea-analysis-b.txt b/nect-api/src/main/resources/prompts/idea-analysis-b.txt new file mode 100644 index 00000000..dbb8101c --- /dev/null +++ b/nect-api/src/main/resources/prompts/idea-analysis-b.txt @@ -0,0 +1,51 @@ +다음 프로젝트 정보를 분석하여 아래 형식에 맞게 응답하세요. + +## 프로젝트 정보 +- 프로젝트명: {{projectName}} +- 프로젝트 요약: {{projectSummary}} +- 타겟 사용자: {{targetUsers}} +- 해결할 문제: {{problemStatement}} +- 핵심 기능 1: {{coreFeature1}} +- 핵심 기능 2: {{coreFeature2}} +- 핵심 기능 3: {{coreFeature3}} +- 플랫폼: {{platform}} +- 레퍼런스 서비스: {{referenceServices}} +- 기술적 도전: {{technicalChallenges}} +- 목표 완료일: {{targetCompletionDate}} + +--- +## 분석 규칙 +- role_field는 다음 값 중에서만 사용하세요: {{roleFields}} + +# 응답 형식 +{ + "team_composition": [ + { + "role_field": "<프로젝트에 필요한 역할 코드>", + "role_field_display_name": "<역할의 한글명>", + "count": <필요한 인원 수> + } + ], + "improvement_points": [ + { + "order": 1, + "title": "<구체적인 개선점 제목>", + "description": "개선이 필요한 구체적인 이유, 현재 문제점, 개선 후 기대 효과를 포함하여 최소 100자 이상 200자 이내로 서술" + }, + { + "order": 2, + "title": "<구체적인 개선점 제목>", + "description": "개선이 필요한 구체적인 이유, 현재 문제점, 개선 후 기대 효과를 포함하여 최소 100자 이상 200자 이내로 서술" + }, + { + "order": 3, + "title": "<구체적인 개선점 제목>", + "description": "개선이 필요한 구체적인 이유, 현재 문제점, 개선 후 기대 효과를 포함하여 최소 100자 이상 200자 이내로 서술" + } + ] +} + +**필수 규칙**: +- "이름1", "제목1", "..." 같은 플레이스홀더를 절대 사용하지 마세요 +- 모든 내용은 프로젝트 정보를 기반으로 구체적으로 작성 +- **모든 텍스트 필드는 반드시 한글로 작성** diff --git a/nect-api/src/main/resources/prompts/idea-analysis-c.txt b/nect-api/src/main/resources/prompts/idea-analysis-c.txt new file mode 100644 index 00000000..ef6bf718 --- /dev/null +++ b/nect-api/src/main/resources/prompts/idea-analysis-c.txt @@ -0,0 +1,61 @@ +다음 프로젝트 정보를 분석하여 아래 형식에 맞게 응답하세요. + +## 프로젝트 정보 +- 프로젝트명: {{projectName}} +- 프로젝트 요약: {{projectSummary}} +- 타겟 사용자: {{targetUsers}} +- 해결할 문제: {{problemStatement}} +- 핵심 기능 1: {{coreFeature1}} +- 핵심 기능 2: {{coreFeature2}} +- 핵심 기능 3: {{coreFeature3}} +- 플랫폼: {{platform}} +- 레퍼런스 서비스: {{referenceServices}} +- 기술적 도전: {{technicalChallenges}} +- 목표 완료일: {{targetCompletionDate}} + +--- +## 분석 규칙 +- total_weeks는 {{totalWeeks}} 입니다. +- 이번 요청은 week_number {{startWeek}}부터 {{endWeek}}까지만 생성하세요. +- team_composition은 다음과 같습니다. role_tasks는 반드시 이 팀 구성과 동일한 role_field/role_field_display_name을 사용하세요. +{{teamComposition}} + +--- +5. **주차별 로드맵**: + - **⚠️ 중요: 이번 요청 범위(week_number {{startWeek}}~{{endWeek}})만 생성하세요.** + - **예: total_weeks가 12이고 요청 범위가 5~8이라면 week_number 5부터 8까지 총 4개의 로드맵 객체를 생성하세요.** + - 각 주차마다 구체적이고 실행 가능한 작업 내용을 작성하세요. + - 각 역할별 태스크는 명사형 작업 목록으로 작성하세요 (최대 3개, 쉼표로 구분). + - **필수**: 각 주차의 role_tasks는 team_composition에 정의된 모든 role_field를 포함해야 합니다. + - **절대 예시로 3-4개만 생성하고 "..." 으로 생략하지 마세요. 반드시 전체 주차를 모두 생성하세요.** + +# 응답 형식 +{ + "weekly_roadmap": [ + { + "week_number": 1, + "week_title": "<1주차의 핵심 목표 (20자 이내)>", + "role_tasks": [ + { + "role_field": "", + "role_field_display_name": "<역할의 한글명>", + "tasks": "<명사형 작업 항목 1~3개를 쉼표로 연결한 문자열. 예: 서비스 기획안 확정, 요구사항 정리, 기능 명세서 초안>" + } + ] + } + ] +} + +**필수 규칙**: +- role_field는 team_composition에 있는 값만 사용 +- "이름1", "제목1", "..." 같은 플레이스홀더를 절대 사용하지 마세요 +- 모든 내용은 프로젝트 정보를 기반으로 구체적으로 작성 +- tasks는 반드시 명사형 작업 항목 1~3개를 쉼표로 연결하여 작성 (문장형 금지, '~합니다' 금지) +- 각 주차의 role_tasks는 team_composition의 모든 역할을 포함 +- **모든 텍스트 필드는 반드시 한글로 작성** +- **텍스트 길이 제한: week_title 20자 이내, tasks는 항목 1~3개** + +**⚠️ weekly_roadmap 생성 필수 규칙 (매우 중요!):** +1. **weekly_roadmap 배열의 길이 = (endWeek - startWeek + 1)** (정확히 일치해야 함!) +2. **week_number는 {{startWeek}}부터 {{endWeek}}까지 빠짐없이 순차적으로 생성** +3. **절대로 1개만 예시로 생성하고 나머지는 생략하지 마세요** diff --git a/nect-client/src/main/java/com/nect/client/openai/config/OpenAiProperties.java b/nect-client/src/main/java/com/nect/client/openai/config/OpenAiProperties.java index f4b6a1ae..d55f9309 100644 --- a/nect-client/src/main/java/com/nect/client/openai/config/OpenAiProperties.java +++ b/nect-client/src/main/java/com/nect/client/openai/config/OpenAiProperties.java @@ -11,11 +11,12 @@ public class OpenAiProperties { private String apiKey; private String baseUrl = "https://api.openai.com"; - private String model = "gpt-4o-mini"; + private String model = "gpt-4.1-mini"; private String fallbackModel = "gpt-4.1"; private int connectTimeoutSeconds = 10; - private int readTimeoutSeconds = 90; + private int readTimeoutSeconds = 120; private int maxRetries = 2; private long initialBackoffMillis = 200; + private int maxOutputToken = 8000; } diff --git a/nect-client/src/main/resources/application-client.yml b/nect-client/src/main/resources/application-client.yml index 88e15aa9..87fb7135 100644 --- a/nect-client/src/main/resources/application-client.yml +++ b/nect-client/src/main/resources/application-client.yml @@ -1,9 +1,10 @@ openai: api-key: ${OPENAI_API_KEY:} base-url: ${OPENAI_BASE_URL:https://api.openai.com} - model: ${OPENAI_MODEL:gpt-4o-mini} + model: ${OPENAI_MODEL:gpt-4.1-mini} fallback-model: ${OPENAI_FALLBACK_MODEL:gpt-4.1} connect-timeout-seconds: ${OPENAI_CONNECT_TIMEOUT_SECONDS:2} read-timeout-seconds: ${OPENAI_READ_TIMEOUT_SECONDS:20} max-retries: ${OPENAI_MAX_RETRIES:2} - initial-backoff-millis: ${OPENAI_INITIAL_BACKOFF_MILLIS:200} \ No newline at end of file + initial-backoff-millis: ${OPENAI_INITIAL_BACKOFF_MILLIS:200} + max-output-token: ${OPENAI_MAX_OUTPUT_TOKEN:4000} diff --git a/nect-core/src/main/java/com/nect/core/entity/analysis/ProjectIdeaAnalysis.java b/nect-core/src/main/java/com/nect/core/entity/analysis/ProjectIdeaAnalysis.java index 76e6bb2b..946b68de 100644 --- a/nect-core/src/main/java/com/nect/core/entity/analysis/ProjectIdeaAnalysis.java +++ b/nect-core/src/main/java/com/nect/core/entity/analysis/ProjectIdeaAnalysis.java @@ -107,6 +107,22 @@ public void addWeeklyRoadmap(AnalysisWeeklyRoadmap weeklyRoadmap) { weeklyRoadmap.setAnalysis(this); } + public void updateDetails(String description, + String recommendedProjectName1, + String recommendedProjectName2, + String recommendedProjectName3, + LocalDate projectStartDate, + LocalDate projectEndDate, + Integer totalWeeks) { + this.description = description; + this.recommendedProjectName1 = recommendedProjectName1; + this.recommendedProjectName2 = recommendedProjectName2; + this.recommendedProjectName3 = recommendedProjectName3; + this.projectStartDate = projectStartDate; + this.projectEndDate = projectEndDate; + this.totalWeeks = totalWeeks; + } + public Set getRequiredRoleFields() { return teamCompositions.stream() .map(AnalysisTeamComposition::getRoleField) @@ -122,4 +138,4 @@ public Set getRoleFieldsForWeek(Integer weekNumber) { .orElse(Collections.emptySet()); } -} \ No newline at end of file +} diff --git a/nect-core/src/main/java/com/nect/core/entity/analysis/enums/AnalysisSaveStatus.java b/nect-core/src/main/java/com/nect/core/entity/analysis/enums/AnalysisSaveStatus.java new file mode 100644 index 00000000..a915ea15 --- /dev/null +++ b/nect-core/src/main/java/com/nect/core/entity/analysis/enums/AnalysisSaveStatus.java @@ -0,0 +1,14 @@ +package com.nect.core.entity.analysis.enums; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum AnalysisSaveStatus { + + PENDING("PENDING") + ; + + private final String status; +} diff --git a/nect-core/src/main/java/com/nect/core/repository/analysis/AnalysisImprovementPointRepository.java b/nect-core/src/main/java/com/nect/core/repository/analysis/AnalysisImprovementPointRepository.java new file mode 100644 index 00000000..98097229 --- /dev/null +++ b/nect-core/src/main/java/com/nect/core/repository/analysis/AnalysisImprovementPointRepository.java @@ -0,0 +1,7 @@ +package com.nect.core.repository.analysis; + +import com.nect.core.entity.analysis.AnalysisImprovementPoint; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface AnalysisImprovementPointRepository extends JpaRepository { +} diff --git a/nect-core/src/main/java/com/nect/core/repository/analysis/AnalysisRoleTaskRepository.java b/nect-core/src/main/java/com/nect/core/repository/analysis/AnalysisRoleTaskRepository.java new file mode 100644 index 00000000..3c17f768 --- /dev/null +++ b/nect-core/src/main/java/com/nect/core/repository/analysis/AnalysisRoleTaskRepository.java @@ -0,0 +1,7 @@ +package com.nect.core.repository.analysis; + +import com.nect.core.entity.analysis.AnalysisRoleTask; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface AnalysisRoleTaskRepository extends JpaRepository { +} diff --git a/nect-core/src/main/java/com/nect/core/repository/analysis/AnalysisTeamCompositionRepository.java b/nect-core/src/main/java/com/nect/core/repository/analysis/AnalysisTeamCompositionRepository.java new file mode 100644 index 00000000..38a071d2 --- /dev/null +++ b/nect-core/src/main/java/com/nect/core/repository/analysis/AnalysisTeamCompositionRepository.java @@ -0,0 +1,7 @@ +package com.nect.core.repository.analysis; + +import com.nect.core.entity.analysis.AnalysisTeamComposition; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface AnalysisTeamCompositionRepository extends JpaRepository { +} diff --git a/nect-core/src/main/java/com/nect/core/repository/analysis/AnalysisWeeklyRoadmapRepository.java b/nect-core/src/main/java/com/nect/core/repository/analysis/AnalysisWeeklyRoadmapRepository.java new file mode 100644 index 00000000..fa0de8c7 --- /dev/null +++ b/nect-core/src/main/java/com/nect/core/repository/analysis/AnalysisWeeklyRoadmapRepository.java @@ -0,0 +1,7 @@ +package com.nect.core.repository.analysis; + +import com.nect.core.entity.analysis.AnalysisWeeklyRoadmap; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface AnalysisWeeklyRoadmapRepository extends JpaRepository { +} diff --git a/nect-core/src/main/resources/application-core.yml b/nect-core/src/main/resources/application-core.yml index 33efe495..c8880152 100644 --- a/nect-core/src/main/resources/application-core.yml +++ b/nect-core/src/main/resources/application-core.yml @@ -14,4 +14,7 @@ hibernate: dialect: ${JPA_DIALECT:org.hibernate.dialect.H2Dialect} jdbc: - time_zone: Asia/Seoul \ No newline at end of file + time_zone: Asia/Seoul + batch_size: ${JPA_BATCH_SIZE:50} + order_inserts: ${JPA_ORDER_INSERTS:true} + order_updates: ${JPA_ORDER_UPDATES:true} From e134be90fa526a38b4ef8a2a2383b798b656ec6d Mon Sep 17 00:00:00 2001 From: junyong Date: Thu, 19 Feb 2026 14:52:47 +0900 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20=EB=A7=A4=EC=B9=AD=20=EA=B0=80?= =?UTF-8?q?=EB=8A=A5=ED=95=9C=20=EB=84=A5=ED=84=B0=20=EC=A1=B0=EA=B1=B4=20?= =?UTF-8?q?=EC=99=84=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/nect/api/domain/home/facade/MainHomeFacade.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/nect-api/src/main/java/com/nect/api/domain/home/facade/MainHomeFacade.java b/nect-api/src/main/java/com/nect/api/domain/home/facade/MainHomeFacade.java index af6858c0..23eb5fbc 100644 --- a/nect-api/src/main/java/com/nect/api/domain/home/facade/MainHomeFacade.java +++ b/nect-api/src/main/java/com/nect/api/domain/home/facade/MainHomeFacade.java @@ -201,9 +201,9 @@ private List responsesFromMembersWithMatchable(List projec .map(user -> { List parts = partsByUserId.getOrDefault(user.getUserId(), List.of()); MemberMatchable matchable = homeMemberQueryService.getMemberMatchable(projectIds, user.getUserId()); - if (filterMatchableOnly && matchable != MemberMatchable.MATCHABLE) { - return null; - } +// if (filterMatchableOnly && matchable != MemberMatchable.MATCHABLE) { +// return null; +// } return HomeMemberItem.of( user.getUserId(), s3Service.getPresignedGetUrl(user.getProfileImageName()),