From 160fe914d1a5f787eee32b8004c19cc07075cde3 Mon Sep 17 00:00:00 2001 From: suheon Date: Sat, 25 Apr 2026 14:06:20 +0900 Subject: [PATCH 1/3] =?UTF-8?q?DP-233:=20AI=20=EB=8B=B5=EB=B3=80=20?= =?UTF-8?q?=EC=9D=91=EB=8B=B5=20=EC=A0=84=EC=B2=B4=20=ED=95=84=EB=93=9C=20?= =?UTF-8?q?=EC=88=98=EC=8B=A0=20=EB=B0=8F=20=EC=A0=80=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AI 서버가 answer_content 외 반환하던 key_points, suggested_tags, related_contents, confidence 필드를 파싱·저장·API 응답에 포함 --- .../community/client/AiAnswerClient.java | 19 ++++----- .../community/dto/AiAnswerResponse.java | 9 ++++ .../community/dto/RelatedContentItem.java | 8 ++++ .../domain/community/entity/AiAnswer.java | 19 +++++++++ .../entity/RelatedContentListConverter.java | 41 +++++++++++++++++++ .../community/service/AiAnswerService.java | 10 +++-- .../global/config/StringListConverter.java | 40 ++++++++++++++++++ .../community/client/AiAnswerClientTest.java | 36 +++++++++++----- .../controller/AiAnswerControllerTest.java | 14 ++++++- .../service/AiAnswerServiceTest.java | 38 ++++++++++++++--- 10 files changed, 203 insertions(+), 31 deletions(-) create mode 100644 src/main/java/com/devpick/domain/community/dto/RelatedContentItem.java create mode 100644 src/main/java/com/devpick/domain/community/entity/RelatedContentListConverter.java create mode 100644 src/main/java/com/devpick/global/config/StringListConverter.java diff --git a/src/main/java/com/devpick/domain/community/client/AiAnswerClient.java b/src/main/java/com/devpick/domain/community/client/AiAnswerClient.java index 2b44495..2593d46 100644 --- a/src/main/java/com/devpick/domain/community/client/AiAnswerClient.java +++ b/src/main/java/com/devpick/domain/community/client/AiAnswerClient.java @@ -1,5 +1,6 @@ package com.devpick.domain.community.client; +import com.devpick.domain.community.dto.RelatedContentItem; import com.devpick.domain.community.entity.AiQuestion; import com.devpick.domain.community.entity.Post; import com.devpick.global.common.exception.DevpickException; @@ -12,6 +13,7 @@ import org.springframework.web.reactive.function.client.WebClientResponseException; import java.util.HashMap; +import java.util.List; import java.util.Map; @Component @@ -27,17 +29,14 @@ public class AiAnswerClient { private String internalKey; public record AiAnswerFastApiResponse( - @JsonProperty("answer_content") String answerContent + @JsonProperty("answer_content") String answerContent, + @JsonProperty("key_points") List keyPoints, + @JsonProperty("suggested_tags") List suggestedTags, + @JsonProperty("related_contents") List relatedContents, + double confidence ) {} - /** - * AI 서버에 답변 생성을 요청한다. - * refined 데이터가 있으면 그것을, 없으면 original 데이터를 refined 필드에도 전달한다. - * - * @param post 원본 게시글 - * @param aiQuestion refine 결과 (없으면 null) - */ - public String generateAnswer(Post post, AiQuestion aiQuestion) { + public AiAnswerFastApiResponse generateAnswer(Post post, AiQuestion aiQuestion) { try { String refinedTitle = aiQuestion != null ? aiQuestion.getRefinedTitle() : post.getTitle(); String refinedContent = aiQuestion != null ? aiQuestion.getRefinedContent() : post.getContent(); @@ -61,7 +60,7 @@ public String generateAnswer(Post post, AiQuestion aiQuestion) { if (response == null || response.answerContent() == null) { throw new DevpickException(ErrorCode.AI_SERVER_ERROR); } - return response.answerContent(); + return response; } catch (WebClientResponseException e) { throw new DevpickException(ErrorCode.AI_SERVER_ERROR); } diff --git a/src/main/java/com/devpick/domain/community/dto/AiAnswerResponse.java b/src/main/java/com/devpick/domain/community/dto/AiAnswerResponse.java index b69511d..fb95bd1 100644 --- a/src/main/java/com/devpick/domain/community/dto/AiAnswerResponse.java +++ b/src/main/java/com/devpick/domain/community/dto/AiAnswerResponse.java @@ -4,12 +4,17 @@ import java.time.Instant; import java.time.ZoneOffset; +import java.util.List; import java.util.UUID; public record AiAnswerResponse( UUID id, UUID postId, String content, + List keyPoints, + List suggestedTags, + List relatedContents, + Double confidence, Boolean isAdopted, Instant createdAt ) { @@ -18,6 +23,10 @@ public static AiAnswerResponse of(AiAnswer aiAnswer) { aiAnswer.getId(), aiAnswer.getPost().getId(), aiAnswer.getContent(), + aiAnswer.getKeyPoints(), + aiAnswer.getSuggestedTags(), + aiAnswer.getRelatedContents(), + aiAnswer.getConfidence(), aiAnswer.getIsAdopted(), aiAnswer.getCreatedAt() != null ? aiAnswer.getCreatedAt().toInstant(ZoneOffset.UTC) : null ); diff --git a/src/main/java/com/devpick/domain/community/dto/RelatedContentItem.java b/src/main/java/com/devpick/domain/community/dto/RelatedContentItem.java new file mode 100644 index 0000000..a5fe70c --- /dev/null +++ b/src/main/java/com/devpick/domain/community/dto/RelatedContentItem.java @@ -0,0 +1,8 @@ +package com.devpick.domain.community.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public record RelatedContentItem( + @JsonProperty("content_id") String contentId, + @JsonProperty("one_line_summary") String oneLineSummary +) {} diff --git a/src/main/java/com/devpick/domain/community/entity/AiAnswer.java b/src/main/java/com/devpick/domain/community/entity/AiAnswer.java index c94a599..477c7d3 100644 --- a/src/main/java/com/devpick/domain/community/entity/AiAnswer.java +++ b/src/main/java/com/devpick/domain/community/entity/AiAnswer.java @@ -1,9 +1,13 @@ package com.devpick.domain.community.entity; +import com.devpick.domain.community.dto.RelatedContentItem; +import com.devpick.global.config.StringListConverter; import com.devpick.global.entity.BaseCreatedEntity; import jakarta.persistence.*; import lombok.*; +import java.util.List; + @Entity @Table(name = "ai_answers", indexes = { @Index(name = "idx_ai_answers_post_id", columnList = "post_id") @@ -24,4 +28,19 @@ public class AiAnswer extends BaseCreatedEntity { @Column(name = "is_adopted", nullable = false) @Builder.Default private Boolean isAdopted = false; + + @Convert(converter = StringListConverter.class) + @Column(name = "key_points", columnDefinition = "jsonb") + private List keyPoints; + + @Convert(converter = StringListConverter.class) + @Column(name = "suggested_tags", columnDefinition = "jsonb") + private List suggestedTags; + + @Convert(converter = RelatedContentListConverter.class) + @Column(name = "related_contents", columnDefinition = "jsonb") + private List relatedContents; + + @Column + private Double confidence; } diff --git a/src/main/java/com/devpick/domain/community/entity/RelatedContentListConverter.java b/src/main/java/com/devpick/domain/community/entity/RelatedContentListConverter.java new file mode 100644 index 0000000..d6c2282 --- /dev/null +++ b/src/main/java/com/devpick/domain/community/entity/RelatedContentListConverter.java @@ -0,0 +1,41 @@ +package com.devpick.domain.community.entity; + +import com.devpick.domain.community.dto.RelatedContentItem; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.persistence.AttributeConverter; +import jakarta.persistence.Converter; +import lombok.extern.slf4j.Slf4j; + +import java.util.List; + +@Slf4j +@Converter +public class RelatedContentListConverter implements AttributeConverter, String> { + + private static final ObjectMapper objectMapper = new ObjectMapper(); + private static final TypeReference> TYPE_REF = new TypeReference<>() {}; + + @Override + public String convertToDatabaseColumn(List attribute) { + if (attribute == null || attribute.isEmpty()) return null; + try { + return objectMapper.writeValueAsString(attribute); + } catch (JsonProcessingException e) { + log.error("Failed to serialize List: {}", e.getMessage()); + return null; + } + } + + @Override + public List convertToEntityAttribute(String dbData) { + if (dbData == null || dbData.isBlank()) return List.of(); + try { + return objectMapper.readValue(dbData, TYPE_REF); + } catch (JsonProcessingException e) { + log.error("Failed to deserialize List: {}", e.getMessage()); + return List.of(); + } + } +} diff --git a/src/main/java/com/devpick/domain/community/service/AiAnswerService.java b/src/main/java/com/devpick/domain/community/service/AiAnswerService.java index 65674b4..b644d40 100644 --- a/src/main/java/com/devpick/domain/community/service/AiAnswerService.java +++ b/src/main/java/com/devpick/domain/community/service/AiAnswerService.java @@ -33,12 +33,16 @@ public AiAnswerResponse generateOrGetAnswer(UUID postId) { return aiAnswerRepository.findByPost_Id(postId) .map(AiAnswerResponse::of) .orElseGet(() -> { - // refine 결과가 있으면 refined 데이터를 사용, 없으면 original 그대로 전달 AiQuestion aiQuestion = aiQuestionRepository.findByPost_Id(postId).orElse(null); - String content = aiAnswerClient.generateAnswer(post, aiQuestion); + AiAnswerClient.AiAnswerFastApiResponse aiResponse = + aiAnswerClient.generateAnswer(post, aiQuestion); AiAnswer saved = aiAnswerRepository.save(AiAnswer.builder() .post(post) - .content(content) + .content(aiResponse.answerContent()) + .keyPoints(aiResponse.keyPoints()) + .suggestedTags(aiResponse.suggestedTags()) + .relatedContents(aiResponse.relatedContents()) + .confidence(aiResponse.confidence()) .build()); return AiAnswerResponse.of(saved); }); diff --git a/src/main/java/com/devpick/global/config/StringListConverter.java b/src/main/java/com/devpick/global/config/StringListConverter.java new file mode 100644 index 0000000..87f1a6c --- /dev/null +++ b/src/main/java/com/devpick/global/config/StringListConverter.java @@ -0,0 +1,40 @@ +package com.devpick.global.config; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.persistence.AttributeConverter; +import jakarta.persistence.Converter; +import lombok.extern.slf4j.Slf4j; + +import java.util.List; + +@Slf4j +@Converter +public class StringListConverter implements AttributeConverter, String> { + + private static final ObjectMapper objectMapper = new ObjectMapper(); + private static final TypeReference> TYPE_REF = new TypeReference<>() {}; + + @Override + public String convertToDatabaseColumn(List attribute) { + if (attribute == null || attribute.isEmpty()) return null; + try { + return objectMapper.writeValueAsString(attribute); + } catch (JsonProcessingException e) { + log.error("Failed to serialize List: {}", e.getMessage()); + return null; + } + } + + @Override + public List convertToEntityAttribute(String dbData) { + if (dbData == null || dbData.isBlank()) return List.of(); + try { + return objectMapper.readValue(dbData, TYPE_REF); + } catch (JsonProcessingException e) { + log.error("Failed to deserialize List: {}", e.getMessage()); + return List.of(); + } + } +} diff --git a/src/test/java/com/devpick/domain/community/client/AiAnswerClientTest.java b/src/test/java/com/devpick/domain/community/client/AiAnswerClientTest.java index b15e4df..3001522 100644 --- a/src/test/java/com/devpick/domain/community/client/AiAnswerClientTest.java +++ b/src/test/java/com/devpick/domain/community/client/AiAnswerClientTest.java @@ -1,5 +1,6 @@ package com.devpick.domain.community.client; +import com.devpick.domain.community.dto.RelatedContentItem; import com.devpick.domain.community.entity.AiQuestion; import com.devpick.domain.community.entity.Post; import com.devpick.domain.user.entity.Level; @@ -18,6 +19,7 @@ import org.springframework.web.reactive.function.client.WebClientResponseException; import reactor.core.publisher.Mono; +import java.util.List; import java.util.UUID; import static org.assertj.core.api.Assertions.assertThat; @@ -78,16 +80,29 @@ private void mockWebClientChain(Object returnValue) { } } + private AiAnswerClient.AiAnswerFastApiResponse buildResponse(String content) { + return new AiAnswerClient.AiAnswerFastApiResponse( + content, + List.of("핵심 포인트 1", "핵심 포인트 2"), + List.of("Spring", "Java"), + List.of(new RelatedContentItem("content-uuid-1", "Spring IoC 설명")), + 0.92 + ); + } + @Test - @DisplayName("AI 서버 정상 응답 시 answerContent를 반환한다") - void generateAnswer_success_returnsContent() { - AiAnswerClient.AiAnswerFastApiResponse fakeResponse = - new AiAnswerClient.AiAnswerFastApiResponse("AI가 생성한 답변"); + @DisplayName("AI 서버 정상 응답 시 전체 응답 객체를 반환한다") + void generateAnswer_success_returnsFullResponse() { + AiAnswerClient.AiAnswerFastApiResponse fakeResponse = buildResponse("AI가 생성한 답변"); mockWebClientChain(fakeResponse); - String result = aiAnswerClient.generateAnswer(post, null); + AiAnswerClient.AiAnswerFastApiResponse result = aiAnswerClient.generateAnswer(post, null); - assertThat(result).isEqualTo("AI가 생성한 답변"); + assertThat(result.answerContent()).isEqualTo("AI가 생성한 답변"); + assertThat(result.keyPoints()).containsExactly("핵심 포인트 1", "핵심 포인트 2"); + assertThat(result.suggestedTags()).containsExactly("Spring", "Java"); + assertThat(result.relatedContents()).hasSize(1); + assertThat(result.confidence()).isEqualTo(0.92); } @Test @@ -100,13 +115,12 @@ void generateAnswer_withRefinedQuestion_usesRefinedData() { .refinedContent("Spring의 IoC 컨테이너 동작 방식을 설명해주세요.") .build(); - AiAnswerClient.AiAnswerFastApiResponse fakeResponse = - new AiAnswerClient.AiAnswerFastApiResponse("refined 기반 AI 답변"); + AiAnswerClient.AiAnswerFastApiResponse fakeResponse = buildResponse("refined 기반 AI 답변"); mockWebClientChain(fakeResponse); - String result = aiAnswerClient.generateAnswer(post, aiQuestion); + AiAnswerClient.AiAnswerFastApiResponse result = aiAnswerClient.generateAnswer(post, aiQuestion); - assertThat(result).isEqualTo("refined 기반 AI 답변"); + assertThat(result.answerContent()).isEqualTo("refined 기반 AI 답변"); } @Test @@ -135,7 +149,7 @@ void generateAnswer_webClientException_throwsAiServerError() { @DisplayName("answerContent가 null이면 AI_SERVER_ERROR 예외가 발생한다") void generateAnswer_nullContent_throwsAiServerError() { AiAnswerClient.AiAnswerFastApiResponse fakeResponse = - new AiAnswerClient.AiAnswerFastApiResponse(null); + new AiAnswerClient.AiAnswerFastApiResponse(null, List.of(), List.of(), List.of(), 0.0); mockWebClientChain(fakeResponse); assertThatThrownBy(() -> aiAnswerClient.generateAnswer(post, null)) diff --git a/src/test/java/com/devpick/domain/community/controller/AiAnswerControllerTest.java b/src/test/java/com/devpick/domain/community/controller/AiAnswerControllerTest.java index c022793..3ea3e83 100644 --- a/src/test/java/com/devpick/domain/community/controller/AiAnswerControllerTest.java +++ b/src/test/java/com/devpick/domain/community/controller/AiAnswerControllerTest.java @@ -1,6 +1,7 @@ package com.devpick.domain.community.controller; import com.devpick.domain.community.dto.AiAnswerResponse; +import com.devpick.domain.community.dto.RelatedContentItem; import com.devpick.domain.community.service.AiAnswerService; import com.devpick.global.common.exception.DevpickException; import com.devpick.global.common.exception.ErrorCode; @@ -59,7 +60,12 @@ void setUp() { aiAnswerResponse = new AiAnswerResponse( UUID.randomUUID(), postId, - "AI가 생성한 답변 내용입니다.", false, + "AI가 생성한 답변 내용입니다.", + List.of("핵심 포인트 1", "핵심 포인트 2"), + List.of("Spring", "Java"), + List.of(new RelatedContentItem("content-uuid-1", "Spring IoC 설명")), + 0.88, + false, Instant.now() ); } @@ -73,6 +79,10 @@ void generateAiAnswer_success_returns200() throws Exception { .andExpect(status().isOk()) .andExpect(jsonPath("$.success").value(true)) .andExpect(jsonPath("$.data.content").value("AI가 생성한 답변 내용입니다.")) + .andExpect(jsonPath("$.data.keyPoints[0]").value("핵심 포인트 1")) + .andExpect(jsonPath("$.data.suggestedTags[0]").value("Spring")) + .andExpect(jsonPath("$.data.relatedContents[0].content_id").value("content-uuid-1")) + .andExpect(jsonPath("$.data.confidence").value(0.88)) .andExpect(jsonPath("$.data.isAdopted").value(false)); } @@ -97,4 +107,4 @@ void generateAiAnswer_aiServerError_returns500() throws Exception { .andExpect(status().isInternalServerError()) .andExpect(jsonPath("$.success").value(false)); } -} \ No newline at end of file +} diff --git a/src/test/java/com/devpick/domain/community/service/AiAnswerServiceTest.java b/src/test/java/com/devpick/domain/community/service/AiAnswerServiceTest.java index c711696..a22c462 100644 --- a/src/test/java/com/devpick/domain/community/service/AiAnswerServiceTest.java +++ b/src/test/java/com/devpick/domain/community/service/AiAnswerServiceTest.java @@ -2,6 +2,7 @@ import com.devpick.domain.community.client.AiAnswerClient; import com.devpick.domain.community.dto.AiAnswerResponse; +import com.devpick.domain.community.dto.RelatedContentItem; import com.devpick.domain.community.entity.AiAnswer; import com.devpick.domain.community.entity.AiQuestion; import com.devpick.domain.community.entity.Post; @@ -20,6 +21,7 @@ import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.test.util.ReflectionTestUtils; +import java.util.List; import java.util.Optional; import java.util.UUID; @@ -47,6 +49,7 @@ class AiAnswerServiceTest { private UUID postId; private Post post; + private AiAnswerClient.AiAnswerFastApiResponse fakeAiResponse; @BeforeEach void setUp() { @@ -57,6 +60,14 @@ void setUp() { .level(Level.JUNIOR) .build(); ReflectionTestUtils.setField(post, "id", postId); + + fakeAiResponse = new AiAnswerClient.AiAnswerFastApiResponse( + "AI가 생성한 답변", + List.of("핵심 포인트 1", "핵심 포인트 2"), + List.of("Spring", "Java"), + List.of(new RelatedContentItem("content-uuid-1", "Spring IoC 설명")), + 0.88 + ); } @Test @@ -79,6 +90,10 @@ void generateOrGetAnswer_existingAnswer_returnsExisting() { AiAnswer existing = AiAnswer.builder() .post(post) .content("기존 AI 답변") + .keyPoints(List.of("포인트 1")) + .suggestedTags(List.of("Java")) + .relatedContents(List.of()) + .confidence(0.9) .build(); UUID answerId = UUID.randomUUID(); ReflectionTestUtils.setField(existing, "id", answerId); @@ -90,17 +105,22 @@ void generateOrGetAnswer_existingAnswer_returnsExisting() { assertThat(result.id()).isEqualTo(answerId); assertThat(result.content()).isEqualTo("기존 AI 답변"); - assertThat(result.postId()).isEqualTo(postId); + assertThat(result.keyPoints()).containsExactly("포인트 1"); + assertThat(result.confidence()).isEqualTo(0.9); verify(aiAnswerClient, never()).generateAnswer(any(), any()); verify(aiAnswerRepository, never()).save(any()); } @Test - @DisplayName("AI 답변이 없으면 FastAPI를 호출하고 저장 후 반환한다 (refined 없을 때)") - void generateOrGetAnswer_noExisting_callsFastApiAndSaves() { + @DisplayName("AI 답변이 없으면 FastAPI를 호출하고 전체 필드를 저장 후 반환한다") + void generateOrGetAnswer_noExisting_callsFastApiAndSavesAllFields() { AiAnswer saved = AiAnswer.builder() .post(post) .content("AI가 생성한 답변") + .keyPoints(fakeAiResponse.keyPoints()) + .suggestedTags(fakeAiResponse.suggestedTags()) + .relatedContents(fakeAiResponse.relatedContents()) + .confidence(fakeAiResponse.confidence()) .build(); UUID answerId = UUID.randomUUID(); ReflectionTestUtils.setField(saved, "id", answerId); @@ -108,13 +128,17 @@ void generateOrGetAnswer_noExisting_callsFastApiAndSaves() { given(postRepository.findById(postId)).willReturn(Optional.of(post)); given(aiAnswerRepository.findByPost_Id(postId)).willReturn(Optional.empty()); given(aiQuestionRepository.findByPost_Id(postId)).willReturn(Optional.empty()); - given(aiAnswerClient.generateAnswer(post, null)).willReturn("AI가 생성한 답변"); + given(aiAnswerClient.generateAnswer(post, null)).willReturn(fakeAiResponse); given(aiAnswerRepository.save(any(AiAnswer.class))).willReturn(saved); AiAnswerResponse result = aiAnswerService.generateOrGetAnswer(postId); assertThat(result.id()).isEqualTo(answerId); assertThat(result.content()).isEqualTo("AI가 생성한 답변"); + assertThat(result.keyPoints()).containsExactly("핵심 포인트 1", "핵심 포인트 2"); + assertThat(result.suggestedTags()).containsExactly("Spring", "Java"); + assertThat(result.relatedContents()).hasSize(1); + assertThat(result.confidence()).isEqualTo(0.88); assertThat(result.isAdopted()).isFalse(); verify(aiAnswerClient).generateAnswer(post, null); verify(aiAnswerRepository).save(any(AiAnswer.class)); @@ -132,6 +156,10 @@ void generateOrGetAnswer_withRefinedQuestion_passesRefinedData() { AiAnswer saved = AiAnswer.builder() .post(post) .content("refined 기반 답변") + .keyPoints(fakeAiResponse.keyPoints()) + .suggestedTags(fakeAiResponse.suggestedTags()) + .relatedContents(fakeAiResponse.relatedContents()) + .confidence(fakeAiResponse.confidence()) .build(); UUID answerId = UUID.randomUUID(); ReflectionTestUtils.setField(saved, "id", answerId); @@ -139,7 +167,7 @@ void generateOrGetAnswer_withRefinedQuestion_passesRefinedData() { given(postRepository.findById(postId)).willReturn(Optional.of(post)); given(aiAnswerRepository.findByPost_Id(postId)).willReturn(Optional.empty()); given(aiQuestionRepository.findByPost_Id(postId)).willReturn(Optional.of(aiQuestion)); - given(aiAnswerClient.generateAnswer(post, aiQuestion)).willReturn("refined 기반 답변"); + given(aiAnswerClient.generateAnswer(post, aiQuestion)).willReturn(fakeAiResponse); given(aiAnswerRepository.save(any(AiAnswer.class))).willReturn(saved); AiAnswerResponse result = aiAnswerService.generateOrGetAnswer(postId); From 0282376ff70722c99fd05bfa261a99aa1ea86ad0 Mon Sep 17 00:00:00 2001 From: suheon Date: Sat, 25 Apr 2026 14:17:06 +0900 Subject: [PATCH 2/3] =?UTF-8?q?DP-233:=20TopContentItem=20rank=20=ED=95=84?= =?UTF-8?q?=EB=93=9C=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EA=B8=B0=EA=B0=84?= =?UTF-8?q?=EB=B3=84=20=ED=8A=B8=EB=A0=8C=EB=93=9C=20=EB=B6=84=EC=84=9D=20?= =?UTF-8?q?=EC=97=94=EB=93=9C=ED=8F=AC=EC=9D=B8=ED=8A=B8=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - TopContentItem에 rank 필드 추가 (AI 서버 payload 기존 포함) - TrendController.getAnalysisByPeriod() 제거 (GET /trends/analysis/{periodStart}) - TrendAnalysisService.getByPeriod() 제거 --- .../trend/controller/TrendController.java | 22 ----------- .../domain/trend/dto/TopContentItem.java | 1 + .../trend/service/TrendAnalysisService.java | 15 -------- .../trend/controller/TrendControllerTest.java | 23 ----------- .../service/TrendAnalysisServiceTest.java | 38 ------------------- 5 files changed, 1 insertion(+), 98 deletions(-) diff --git a/src/main/java/com/devpick/domain/trend/controller/TrendController.java b/src/main/java/com/devpick/domain/trend/controller/TrendController.java index e7026ed..8526539 100644 --- a/src/main/java/com/devpick/domain/trend/controller/TrendController.java +++ b/src/main/java/com/devpick/domain/trend/controller/TrendController.java @@ -10,15 +10,12 @@ import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; -import org.springframework.format.annotation.DateTimeFormat; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; -import java.time.LocalDate; import java.util.UUID; @Tag(name = "Trend", description = "트렌딩 키워드 / 트렌드 분석 API") @@ -54,23 +51,4 @@ public ApiResponse getLatestAnalysis( return ApiResponse.ok(trendAnalysisService.getLatest(unit, scope)); } - @Operation(summary = "특정 기간 트렌드 분석 조회", - description = "period_start 기준의 트렌드 분석 결과를 반환합니다. Redis 캐시(24h) 적용.") - @ApiResponses({ - @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "조회 성공"), - @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "잘못된 날짜 형식"), - @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "인증 필요"), - @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "트렌드 분석 결과 없음") - }) - @GetMapping("/analysis/{periodStart}") - public ApiResponse getAnalysisByPeriod( - @AuthenticationPrincipal UUID userId, - @Parameter(description = "기간 시작일 (yyyy-MM-dd)", required = true, example = "2026-04-14") - @PathVariable @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate periodStart, - @Parameter(description = "집계 단위 (weekly/daily)", example = "weekly") - @RequestParam(defaultValue = "weekly") String unit, - @Parameter(description = "범위 (global)", example = "global") - @RequestParam(defaultValue = "global") String scope) { - return ApiResponse.ok(trendAnalysisService.getByPeriod(unit, scope, periodStart)); - } } diff --git a/src/main/java/com/devpick/domain/trend/dto/TopContentItem.java b/src/main/java/com/devpick/domain/trend/dto/TopContentItem.java index 1a64dc2..2eabe99 100644 --- a/src/main/java/com/devpick/domain/trend/dto/TopContentItem.java +++ b/src/main/java/com/devpick/domain/trend/dto/TopContentItem.java @@ -5,6 +5,7 @@ import java.util.List; public record TopContentItem( + Integer rank, String id, String title, @JsonAlias("translated_title") String translatedTitle, diff --git a/src/main/java/com/devpick/domain/trend/service/TrendAnalysisService.java b/src/main/java/com/devpick/domain/trend/service/TrendAnalysisService.java index 36c0c67..e2e799e 100644 --- a/src/main/java/com/devpick/domain/trend/service/TrendAnalysisService.java +++ b/src/main/java/com/devpick/domain/trend/service/TrendAnalysisService.java @@ -42,21 +42,6 @@ public TrendAnalysisResponse getLatest(String unit, String scope) { return response; } - @Transactional(readOnly = true) - public TrendAnalysisResponse getByPeriod(String unit, String scope, LocalDate periodStart) { - String key = "trend:analysis:" + unit + ":" + scope + ":" + periodStart; - TrendAnalysisResponse cached = getFromRedis(key); - if (cached != null) return cached; - - TrendSnapshot snapshot = trendSnapshotRepository - .findByUnitAndScopeAndPeriodStart(unit, scope, periodStart) - .orElseThrow(() -> new DevpickException(ErrorCode.TREND_NOT_FOUND)); - - TrendAnalysisResponse response = parsePayload(snapshot.getPayload()); - saveToRedis(key, response, resolveTtl(unit)); - return response; - } - public void evictCache(String unit, String scope, LocalDate periodStart) { List keys = new ArrayList<>(); keys.add("trend:analysis:" + unit + ":" + scope + ":latest"); diff --git a/src/test/java/com/devpick/domain/trend/controller/TrendControllerTest.java b/src/test/java/com/devpick/domain/trend/controller/TrendControllerTest.java index 6ad3690..05ca3c0 100644 --- a/src/test/java/com/devpick/domain/trend/controller/TrendControllerTest.java +++ b/src/test/java/com/devpick/domain/trend/controller/TrendControllerTest.java @@ -114,27 +114,4 @@ void getLatestAnalysis_notFound_returns404() throws Exception { .andExpect(jsonPath("$.success").value(false)); } - @Test - @DisplayName("GET /trends/analysis/{periodStart} - 특정 기간 트렌드 분석 조회 성공 시 200 반환") - void getAnalysisByPeriod_success_returns200() throws Exception { - LocalDate periodStart = LocalDate.of(2026, 4, 14); - TrendAnalysisResponse response = new TrendAnalysisResponse( - "weekly", periodStart, LocalDate.of(2026, 4, 20), - "2026년 4월 3주차", List.of(), "요약", "컬렉션 요약", List.of()); - given(trendAnalysisService.getByPeriod(eq("weekly"), eq("global"), eq(periodStart))) - .willReturn(response); - - mockMvc.perform(get("/trends/analysis/2026-04-14")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.success").value(true)) - .andExpect(jsonPath("$.data.unit").value("weekly")); - } - - @Test - @DisplayName("GET /trends/analysis/{periodStart} - 잘못된 날짜 형식이면 400 반환") - void getAnalysisByPeriod_invalidDate_returns400() throws Exception { - mockMvc.perform(get("/trends/analysis/not-a-date")) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.success").value(false)); - } } diff --git a/src/test/java/com/devpick/domain/trend/service/TrendAnalysisServiceTest.java b/src/test/java/com/devpick/domain/trend/service/TrendAnalysisServiceTest.java index cc178b4..ea35221 100644 --- a/src/test/java/com/devpick/domain/trend/service/TrendAnalysisServiceTest.java +++ b/src/test/java/com/devpick/domain/trend/service/TrendAnalysisServiceTest.java @@ -129,44 +129,6 @@ void getLatest_redisFails_fallsBackToPg() { assertThat(result.unit()).isEqualTo(UNIT); } - @Test - @DisplayName("getByPeriod — Redis 캐시 hit 시 repository를 호출하지 않는다") - void getByPeriod_cacheHit_returnsFromRedis() throws Exception { - String json = objectMapper.writeValueAsString(sampleResponse); - given(valueOperations.get(anyString())).willReturn(json); - - TrendAnalysisResponse result = trendAnalysisService.getByPeriod(UNIT, SCOPE, PERIOD_START); - - assertThat(result.periodStart()).isEqualTo(PERIOD_START); - verify(trendSnapshotRepository, never()).findByUnitAndScopeAndPeriodStart(any(), any(), any()); - } - - @Test - @DisplayName("getByPeriod — Redis miss 시 PG를 조회한다") - void getByPeriod_cacheMiss_queriesPg() { - given(valueOperations.get(anyString())).willReturn(null); - given(trendSnapshotRepository.findByUnitAndScopeAndPeriodStart(UNIT, SCOPE, PERIOD_START)) - .willReturn(Optional.of(sampleSnapshot)); - - TrendAnalysisResponse result = trendAnalysisService.getByPeriod(UNIT, SCOPE, PERIOD_START); - - assertThat(result.periodStart()).isEqualTo(PERIOD_START); - verify(trendSnapshotRepository).findByUnitAndScopeAndPeriodStart(eq(UNIT), eq(SCOPE), eq(PERIOD_START)); - } - - @Test - @DisplayName("getByPeriod — PG에 해당 기간 데이터 없으면 TREND_NOT_FOUND 예외를 던진다") - void getByPeriod_noData_throwsTrendNotFound() { - given(valueOperations.get(anyString())).willReturn(null); - given(trendSnapshotRepository.findByUnitAndScopeAndPeriodStart(UNIT, SCOPE, PERIOD_START)) - .willReturn(Optional.empty()); - - assertThatThrownBy(() -> trendAnalysisService.getByPeriod(UNIT, SCOPE, PERIOD_START)) - .isInstanceOf(DevpickException.class) - .satisfies(e -> assertThat(((DevpickException) e).getErrorCode()) - .isEqualTo(ErrorCode.TREND_NOT_FOUND)); - } - @Test @DisplayName("getLatest — Redis 저장 실패해도 응답을 정상 반환한다") void getLatest_redisStoreFails_stillReturnsResponse() { From 0f0553e4239735c1172364b79c505300ba4e2c4d Mon Sep 17 00:00:00 2001 From: suheon Date: Sat, 25 Apr 2026 14:47:09 +0900 Subject: [PATCH 3/3] =?UTF-8?q?DP-233:=20AI=20=EB=8B=B5=EB=B3=80=20?= =?UTF-8?q?=EC=9D=91=EB=8B=B5=EC=97=90=EC=84=9C=20related=5Fcontents=20?= =?UTF-8?q?=ED=95=84=EB=93=9C=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AI 서버 응답에 포함되나 BE에서 파싱/저장하지 않음 --- .../community/client/AiAnswerClient.java | 2 - .../community/dto/AiAnswerResponse.java | 2 - .../community/dto/RelatedContentItem.java | 8 ---- .../domain/community/entity/AiAnswer.java | 5 --- .../entity/RelatedContentListConverter.java | 41 ------------------- .../community/service/AiAnswerService.java | 1 - .../community/client/AiAnswerClientTest.java | 5 +-- .../controller/AiAnswerControllerTest.java | 3 -- .../service/AiAnswerServiceTest.java | 7 +--- 9 files changed, 2 insertions(+), 72 deletions(-) delete mode 100644 src/main/java/com/devpick/domain/community/dto/RelatedContentItem.java delete mode 100644 src/main/java/com/devpick/domain/community/entity/RelatedContentListConverter.java diff --git a/src/main/java/com/devpick/domain/community/client/AiAnswerClient.java b/src/main/java/com/devpick/domain/community/client/AiAnswerClient.java index 2593d46..9758c54 100644 --- a/src/main/java/com/devpick/domain/community/client/AiAnswerClient.java +++ b/src/main/java/com/devpick/domain/community/client/AiAnswerClient.java @@ -1,6 +1,5 @@ package com.devpick.domain.community.client; -import com.devpick.domain.community.dto.RelatedContentItem; import com.devpick.domain.community.entity.AiQuestion; import com.devpick.domain.community.entity.Post; import com.devpick.global.common.exception.DevpickException; @@ -32,7 +31,6 @@ public record AiAnswerFastApiResponse( @JsonProperty("answer_content") String answerContent, @JsonProperty("key_points") List keyPoints, @JsonProperty("suggested_tags") List suggestedTags, - @JsonProperty("related_contents") List relatedContents, double confidence ) {} diff --git a/src/main/java/com/devpick/domain/community/dto/AiAnswerResponse.java b/src/main/java/com/devpick/domain/community/dto/AiAnswerResponse.java index fb95bd1..1d28a0c 100644 --- a/src/main/java/com/devpick/domain/community/dto/AiAnswerResponse.java +++ b/src/main/java/com/devpick/domain/community/dto/AiAnswerResponse.java @@ -13,7 +13,6 @@ public record AiAnswerResponse( String content, List keyPoints, List suggestedTags, - List relatedContents, Double confidence, Boolean isAdopted, Instant createdAt @@ -25,7 +24,6 @@ public static AiAnswerResponse of(AiAnswer aiAnswer) { aiAnswer.getContent(), aiAnswer.getKeyPoints(), aiAnswer.getSuggestedTags(), - aiAnswer.getRelatedContents(), aiAnswer.getConfidence(), aiAnswer.getIsAdopted(), aiAnswer.getCreatedAt() != null ? aiAnswer.getCreatedAt().toInstant(ZoneOffset.UTC) : null diff --git a/src/main/java/com/devpick/domain/community/dto/RelatedContentItem.java b/src/main/java/com/devpick/domain/community/dto/RelatedContentItem.java deleted file mode 100644 index a5fe70c..0000000 --- a/src/main/java/com/devpick/domain/community/dto/RelatedContentItem.java +++ /dev/null @@ -1,8 +0,0 @@ -package com.devpick.domain.community.dto; - -import com.fasterxml.jackson.annotation.JsonProperty; - -public record RelatedContentItem( - @JsonProperty("content_id") String contentId, - @JsonProperty("one_line_summary") String oneLineSummary -) {} diff --git a/src/main/java/com/devpick/domain/community/entity/AiAnswer.java b/src/main/java/com/devpick/domain/community/entity/AiAnswer.java index 477c7d3..bfe686e 100644 --- a/src/main/java/com/devpick/domain/community/entity/AiAnswer.java +++ b/src/main/java/com/devpick/domain/community/entity/AiAnswer.java @@ -1,6 +1,5 @@ package com.devpick.domain.community.entity; -import com.devpick.domain.community.dto.RelatedContentItem; import com.devpick.global.config.StringListConverter; import com.devpick.global.entity.BaseCreatedEntity; import jakarta.persistence.*; @@ -37,10 +36,6 @@ public class AiAnswer extends BaseCreatedEntity { @Column(name = "suggested_tags", columnDefinition = "jsonb") private List suggestedTags; - @Convert(converter = RelatedContentListConverter.class) - @Column(name = "related_contents", columnDefinition = "jsonb") - private List relatedContents; - @Column private Double confidence; } diff --git a/src/main/java/com/devpick/domain/community/entity/RelatedContentListConverter.java b/src/main/java/com/devpick/domain/community/entity/RelatedContentListConverter.java deleted file mode 100644 index d6c2282..0000000 --- a/src/main/java/com/devpick/domain/community/entity/RelatedContentListConverter.java +++ /dev/null @@ -1,41 +0,0 @@ -package com.devpick.domain.community.entity; - -import com.devpick.domain.community.dto.RelatedContentItem; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.core.type.TypeReference; -import com.fasterxml.jackson.databind.ObjectMapper; -import jakarta.persistence.AttributeConverter; -import jakarta.persistence.Converter; -import lombok.extern.slf4j.Slf4j; - -import java.util.List; - -@Slf4j -@Converter -public class RelatedContentListConverter implements AttributeConverter, String> { - - private static final ObjectMapper objectMapper = new ObjectMapper(); - private static final TypeReference> TYPE_REF = new TypeReference<>() {}; - - @Override - public String convertToDatabaseColumn(List attribute) { - if (attribute == null || attribute.isEmpty()) return null; - try { - return objectMapper.writeValueAsString(attribute); - } catch (JsonProcessingException e) { - log.error("Failed to serialize List: {}", e.getMessage()); - return null; - } - } - - @Override - public List convertToEntityAttribute(String dbData) { - if (dbData == null || dbData.isBlank()) return List.of(); - try { - return objectMapper.readValue(dbData, TYPE_REF); - } catch (JsonProcessingException e) { - log.error("Failed to deserialize List: {}", e.getMessage()); - return List.of(); - } - } -} diff --git a/src/main/java/com/devpick/domain/community/service/AiAnswerService.java b/src/main/java/com/devpick/domain/community/service/AiAnswerService.java index b644d40..13e5af2 100644 --- a/src/main/java/com/devpick/domain/community/service/AiAnswerService.java +++ b/src/main/java/com/devpick/domain/community/service/AiAnswerService.java @@ -41,7 +41,6 @@ public AiAnswerResponse generateOrGetAnswer(UUID postId) { .content(aiResponse.answerContent()) .keyPoints(aiResponse.keyPoints()) .suggestedTags(aiResponse.suggestedTags()) - .relatedContents(aiResponse.relatedContents()) .confidence(aiResponse.confidence()) .build()); return AiAnswerResponse.of(saved); diff --git a/src/test/java/com/devpick/domain/community/client/AiAnswerClientTest.java b/src/test/java/com/devpick/domain/community/client/AiAnswerClientTest.java index 3001522..8a5bcfc 100644 --- a/src/test/java/com/devpick/domain/community/client/AiAnswerClientTest.java +++ b/src/test/java/com/devpick/domain/community/client/AiAnswerClientTest.java @@ -1,6 +1,5 @@ package com.devpick.domain.community.client; -import com.devpick.domain.community.dto.RelatedContentItem; import com.devpick.domain.community.entity.AiQuestion; import com.devpick.domain.community.entity.Post; import com.devpick.domain.user.entity.Level; @@ -85,7 +84,6 @@ private AiAnswerClient.AiAnswerFastApiResponse buildResponse(String content) { content, List.of("핵심 포인트 1", "핵심 포인트 2"), List.of("Spring", "Java"), - List.of(new RelatedContentItem("content-uuid-1", "Spring IoC 설명")), 0.92 ); } @@ -101,7 +99,6 @@ void generateAnswer_success_returnsFullResponse() { assertThat(result.answerContent()).isEqualTo("AI가 생성한 답변"); assertThat(result.keyPoints()).containsExactly("핵심 포인트 1", "핵심 포인트 2"); assertThat(result.suggestedTags()).containsExactly("Spring", "Java"); - assertThat(result.relatedContents()).hasSize(1); assertThat(result.confidence()).isEqualTo(0.92); } @@ -149,7 +146,7 @@ void generateAnswer_webClientException_throwsAiServerError() { @DisplayName("answerContent가 null이면 AI_SERVER_ERROR 예외가 발생한다") void generateAnswer_nullContent_throwsAiServerError() { AiAnswerClient.AiAnswerFastApiResponse fakeResponse = - new AiAnswerClient.AiAnswerFastApiResponse(null, List.of(), List.of(), List.of(), 0.0); + new AiAnswerClient.AiAnswerFastApiResponse(null, List.of(), List.of(), 0.0); mockWebClientChain(fakeResponse); assertThatThrownBy(() -> aiAnswerClient.generateAnswer(post, null)) diff --git a/src/test/java/com/devpick/domain/community/controller/AiAnswerControllerTest.java b/src/test/java/com/devpick/domain/community/controller/AiAnswerControllerTest.java index 3ea3e83..43de506 100644 --- a/src/test/java/com/devpick/domain/community/controller/AiAnswerControllerTest.java +++ b/src/test/java/com/devpick/domain/community/controller/AiAnswerControllerTest.java @@ -1,7 +1,6 @@ package com.devpick.domain.community.controller; import com.devpick.domain.community.dto.AiAnswerResponse; -import com.devpick.domain.community.dto.RelatedContentItem; import com.devpick.domain.community.service.AiAnswerService; import com.devpick.global.common.exception.DevpickException; import com.devpick.global.common.exception.ErrorCode; @@ -63,7 +62,6 @@ void setUp() { "AI가 생성한 답변 내용입니다.", List.of("핵심 포인트 1", "핵심 포인트 2"), List.of("Spring", "Java"), - List.of(new RelatedContentItem("content-uuid-1", "Spring IoC 설명")), 0.88, false, Instant.now() @@ -81,7 +79,6 @@ void generateAiAnswer_success_returns200() throws Exception { .andExpect(jsonPath("$.data.content").value("AI가 생성한 답변 내용입니다.")) .andExpect(jsonPath("$.data.keyPoints[0]").value("핵심 포인트 1")) .andExpect(jsonPath("$.data.suggestedTags[0]").value("Spring")) - .andExpect(jsonPath("$.data.relatedContents[0].content_id").value("content-uuid-1")) .andExpect(jsonPath("$.data.confidence").value(0.88)) .andExpect(jsonPath("$.data.isAdopted").value(false)); } diff --git a/src/test/java/com/devpick/domain/community/service/AiAnswerServiceTest.java b/src/test/java/com/devpick/domain/community/service/AiAnswerServiceTest.java index a22c462..f9d83f6 100644 --- a/src/test/java/com/devpick/domain/community/service/AiAnswerServiceTest.java +++ b/src/test/java/com/devpick/domain/community/service/AiAnswerServiceTest.java @@ -2,7 +2,6 @@ import com.devpick.domain.community.client.AiAnswerClient; import com.devpick.domain.community.dto.AiAnswerResponse; -import com.devpick.domain.community.dto.RelatedContentItem; import com.devpick.domain.community.entity.AiAnswer; import com.devpick.domain.community.entity.AiQuestion; import com.devpick.domain.community.entity.Post; @@ -65,7 +64,6 @@ void setUp() { "AI가 생성한 답변", List.of("핵심 포인트 1", "핵심 포인트 2"), List.of("Spring", "Java"), - List.of(new RelatedContentItem("content-uuid-1", "Spring IoC 설명")), 0.88 ); } @@ -92,7 +90,6 @@ void generateOrGetAnswer_existingAnswer_returnsExisting() { .content("기존 AI 답변") .keyPoints(List.of("포인트 1")) .suggestedTags(List.of("Java")) - .relatedContents(List.of()) .confidence(0.9) .build(); UUID answerId = UUID.randomUUID(); @@ -107,6 +104,7 @@ void generateOrGetAnswer_existingAnswer_returnsExisting() { assertThat(result.content()).isEqualTo("기존 AI 답변"); assertThat(result.keyPoints()).containsExactly("포인트 1"); assertThat(result.confidence()).isEqualTo(0.9); + assertThat(result.suggestedTags()).containsExactly("Java"); verify(aiAnswerClient, never()).generateAnswer(any(), any()); verify(aiAnswerRepository, never()).save(any()); } @@ -119,7 +117,6 @@ void generateOrGetAnswer_noExisting_callsFastApiAndSavesAllFields() { .content("AI가 생성한 답변") .keyPoints(fakeAiResponse.keyPoints()) .suggestedTags(fakeAiResponse.suggestedTags()) - .relatedContents(fakeAiResponse.relatedContents()) .confidence(fakeAiResponse.confidence()) .build(); UUID answerId = UUID.randomUUID(); @@ -137,7 +134,6 @@ void generateOrGetAnswer_noExisting_callsFastApiAndSavesAllFields() { assertThat(result.content()).isEqualTo("AI가 생성한 답변"); assertThat(result.keyPoints()).containsExactly("핵심 포인트 1", "핵심 포인트 2"); assertThat(result.suggestedTags()).containsExactly("Spring", "Java"); - assertThat(result.relatedContents()).hasSize(1); assertThat(result.confidence()).isEqualTo(0.88); assertThat(result.isAdopted()).isFalse(); verify(aiAnswerClient).generateAnswer(post, null); @@ -158,7 +154,6 @@ void generateOrGetAnswer_withRefinedQuestion_passesRefinedData() { .content("refined 기반 답변") .keyPoints(fakeAiResponse.keyPoints()) .suggestedTags(fakeAiResponse.suggestedTags()) - .relatedContents(fakeAiResponse.relatedContents()) .confidence(fakeAiResponse.confidence()) .build(); UUID answerId = UUID.randomUUID();