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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
import org.springframework.web.reactive.function.client.WebClientResponseException;

import java.util.HashMap;
import java.util.List;
import java.util.Map;

@Component
Expand All @@ -27,17 +28,13 @@ public class AiAnswerClient {
private String internalKey;

public record AiAnswerFastApiResponse(
@JsonProperty("answer_content") String answerContent
@JsonProperty("answer_content") String answerContent,
@JsonProperty("key_points") List<String> keyPoints,
@JsonProperty("suggested_tags") List<String> suggestedTags,
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();
Expand All @@ -61,7 +58,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);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,16 @@

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<String> keyPoints,
List<String> suggestedTags,
Double confidence,
Boolean isAdopted,
Instant createdAt
) {
Expand All @@ -18,6 +22,9 @@ public static AiAnswerResponse of(AiAnswer aiAnswer) {
aiAnswer.getId(),
aiAnswer.getPost().getId(),
aiAnswer.getContent(),
aiAnswer.getKeyPoints(),
aiAnswer.getSuggestedTags(),
aiAnswer.getConfidence(),
aiAnswer.getIsAdopted(),
aiAnswer.getCreatedAt() != null ? aiAnswer.getCreatedAt().toInstant(ZoneOffset.UTC) : null
);
Expand Down
14 changes: 14 additions & 0 deletions src/main/java/com/devpick/domain/community/entity/AiAnswer.java
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
package com.devpick.domain.community.entity;

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")
Expand All @@ -24,4 +27,15 @@ 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<String> keyPoints;

@Convert(converter = StringListConverter.class)
@Column(name = "suggested_tags", columnDefinition = "jsonb")
private List<String> suggestedTags;

@Column
private Double confidence;
}
Original file line number Diff line number Diff line change
Expand Up @@ -33,12 +33,15 @@ 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())
.confidence(aiResponse.confidence())
.build());
return AiAnswerResponse.of(saved);
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -54,23 +51,4 @@ public ApiResponse<TrendAnalysisResponse> 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<TrendAnalysisResponse> 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));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import java.util.List;

public record TopContentItem(
Integer rank,
String id,
String title,
@JsonAlias("translated_title") String translatedTitle,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> keys = new ArrayList<>();
keys.add("trend:analysis:" + unit + ":" + scope + ":latest");
Expand Down
40 changes: 40 additions & 0 deletions src/main/java/com/devpick/global/config/StringListConverter.java
Original file line number Diff line number Diff line change
@@ -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<List<String>, String> {

private static final ObjectMapper objectMapper = new ObjectMapper();
private static final TypeReference<List<String>> TYPE_REF = new TypeReference<>() {};

@Override
public String convertToDatabaseColumn(List<String> attribute) {
if (attribute == null || attribute.isEmpty()) return null;
try {
return objectMapper.writeValueAsString(attribute);
} catch (JsonProcessingException e) {
log.error("Failed to serialize List<String>: {}", e.getMessage());
return null;
}
}

@Override
public List<String> 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<String>: {}", e.getMessage());
return List.of();
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,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;
Expand Down Expand Up @@ -78,16 +79,27 @@ private void mockWebClientChain(Object returnValue) {
}
}

private AiAnswerClient.AiAnswerFastApiResponse buildResponse(String content) {
return new AiAnswerClient.AiAnswerFastApiResponse(
content,
List.of("핵심 포인트 1", "핵심 포인트 2"),
List.of("Spring", "Java"),
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.confidence()).isEqualTo(0.92);
}

@Test
Expand All @@ -100,13 +112,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
Expand Down Expand Up @@ -135,7 +146,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(), 0.0);
mockWebClientChain(fakeResponse);

assertThatThrownBy(() -> aiAnswerClient.generateAnswer(post, null))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,11 @@ void setUp() {

aiAnswerResponse = new AiAnswerResponse(
UUID.randomUUID(), postId,
"AI가 생성한 답변 내용입니다.", false,
"AI가 생성한 답변 내용입니다.",
List.of("핵심 포인트 1", "핵심 포인트 2"),
List.of("Spring", "Java"),
0.88,
false,
Instant.now()
);
}
Expand All @@ -73,6 +77,9 @@ 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.confidence").value(0.88))
.andExpect(jsonPath("$.data.isAdopted").value(false));
}

Expand All @@ -97,4 +104,4 @@ void generateAiAnswer_aiServerError_returns500() throws Exception {
.andExpect(status().isInternalServerError())
.andExpect(jsonPath("$.success").value(false));
}
}
}
Loading
Loading