diff --git a/src/main/java/com/perfact/be/domain/chat/controller/ChatController.java b/src/main/java/com/perfact/be/domain/chat/controller/ChatController.java index 9fc192c..dfb2b75 100644 --- a/src/main/java/com/perfact/be/domain/chat/controller/ChatController.java +++ b/src/main/java/com/perfact/be/domain/chat/controller/ChatController.java @@ -1,61 +1,49 @@ package com.perfact.be.domain.chat.controller; -import com.perfact.be.domain.chat.dto.ChatLogListResponseDTO; -import com.perfact.be.domain.chat.dto.ChatRequestDTO; -import com.perfact.be.domain.chat.dto.ChatResponseDTO; -import com.perfact.be.domain.chat.dto.RecommendQuestionsResponseDTO; + +import com.perfact.be.domain.chat.dto.ChatRequest; +import com.perfact.be.domain.chat.dto.ChatResponse; import com.perfact.be.domain.chat.service.ChatService; import com.perfact.be.global.apiPayload.ApiResponse; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; +import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; -@Slf4j +@Validated @RestController @RequestMapping("/api/report") @RequiredArgsConstructor @Tag(name = "Chat", description = "챗봇 API") public class ChatController { - private final ChatService chatService; + private final ChatService chatServiceImpl; @Operation(summary = "리포트 기반 채팅", description = "특정 리포트의 분석 결과를 바탕으로 AI와 채팅합니다.") @PostMapping("/{reportId}/chat") - public ApiResponse sendMessage( + public ApiResponse sendMessage( @Parameter(description = "리포트 ID", required = true, example = "1") @PathVariable Long reportId, - @Parameter(description = "채팅 요청", required = true) @RequestBody ChatRequestDTO request) { - - log.info("채팅 요청 - reportId: {}, userInput: {}", reportId, request.getUserInput()); - - ChatResponseDTO response = chatService.sendMessage(reportId, request); - + @Parameter(description = "채팅 요청", required = true) @RequestBody ChatRequest request) { + ChatResponse.ChatResponseDTO response = chatServiceImpl.sendMessage(reportId, request); return ApiResponse.onSuccess(response); } @Operation(summary = "채팅 로그 조회", description = "특정 리포트의 채팅 로그를 시간 순으로 조회합니다.") @GetMapping("/{reportId}/chat") - public ApiResponse getChatLogs( + public ApiResponse getChatLogs( @Parameter(description = "리포트 ID", required = true, example = "1") @PathVariable Long reportId) { - - log.info("채팅 로그 조회 요청 - reportId: {}", reportId); - - ChatLogListResponseDTO response = chatService.getChatLogs(reportId); - + ChatResponse.ChatLogListResponseDTO response = chatServiceImpl.getChatLogs(reportId); return ApiResponse.onSuccess(response); } @Operation(summary = "추천 질문 조회", description = "특정 리포트의 분석 결과를 바탕으로 AI가 추천하는 질문을 조회합니다.") @GetMapping("/{reportId}/chat/recommend") - public ApiResponse getRecommendQuestions( + public ApiResponse getRecommendQuestions( @Parameter(description = "리포트 ID", required = true, example = "1") @PathVariable Long reportId) { - - log.info("추천 질문 조회 요청 - reportId: {}", reportId); - - RecommendQuestionsResponseDTO response = chatService.getRecommendQuestions(reportId); - + ChatResponse.RecommendQuestionsResponseDTO response = chatServiceImpl.getRecommendQuestions(reportId); return ApiResponse.onSuccess(response); } + } \ No newline at end of file diff --git a/src/main/java/com/perfact/be/domain/chat/converter/ChatLogConverter.java b/src/main/java/com/perfact/be/domain/chat/converter/ChatLogConverter.java index 9c0faa0..58eb4fc 100644 --- a/src/main/java/com/perfact/be/domain/chat/converter/ChatLogConverter.java +++ b/src/main/java/com/perfact/be/domain/chat/converter/ChatLogConverter.java @@ -1,6 +1,6 @@ package com.perfact.be.domain.chat.converter; -import com.perfact.be.domain.chat.dto.ChatLogListResponseDTO; +import com.perfact.be.domain.chat.dto.ChatResponse; import com.perfact.be.domain.chat.entity.ChatLog; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; @@ -11,31 +11,42 @@ @Slf4j @Component public class ChatLogConverter { - - // ChatLog 엔티티를 ChatLogListResponseDTO로 변환 - public ChatLogListResponseDTO toResponseDto(Long reportId, List chatLogs) { - log.debug("ChatLog 변환 시작 - reportId: {}, chatLogs 개수: {}", reportId, chatLogs.size()); - - List chatLogDTOs = chatLogs.stream() + // reportId, List -> ChatLogListResponseDTO + public ChatResponse.ChatLogListResponseDTO toResponseDto(Long reportId, List chatLogs) { + List chatLogDTOs = chatLogs.stream() .map(this::toChatLogDto) .collect(Collectors.toList()); - ChatLogListResponseDTO result = ChatLogListResponseDTO.builder() + ChatResponse.ChatLogListResponseDTO result = ChatResponse.ChatLogListResponseDTO.builder() .reportId(reportId) .chatLogs(chatLogDTOs) .build(); - log.debug("ChatLog 변환 완료 - 결과: {}", result); return result; } - // ChatLog 엔티티를 ChatLogDTO로 변환 - private ChatLogListResponseDTO.ChatLogDTO toChatLogDto(ChatLog chatLog) { - return ChatLogListResponseDTO.ChatLogDTO.builder() + // ChatLogEntity -> ChatLogDTO + private ChatResponse.ChatLogDTO toChatLogDto(ChatLog chatLog) { + return ChatResponse.ChatLogDTO.builder() .chatId(chatLog.getChatId()) .senderType(chatLog.getSenderType()) .message(chatLog.getMessage()) .createdAt(chatLog.getCreatedAt()) .build(); } + + // String -> ChatResponseDTO + public ChatResponse.ChatResponseDTO toCharResponseDto(String polishedResponse) { + return ChatResponse.ChatResponseDTO.builder() + .aiResponse(polishedResponse) + .build(); + } + + // List -> RecommendQuestionsResponseDTO + public ChatResponse.RecommendQuestionsResponseDTO toRecommendQuestionsResponseDto(List questions) { + return ChatResponse.RecommendQuestionsResponseDTO.builder() + .questions(questions) + .build(); + } + } \ No newline at end of file diff --git a/src/main/java/com/perfact/be/domain/chat/converter/ChatMessageConverter.java b/src/main/java/com/perfact/be/domain/chat/converter/ChatMessageConverter.java new file mode 100644 index 0000000..a394a36 --- /dev/null +++ b/src/main/java/com/perfact/be/domain/chat/converter/ChatMessageConverter.java @@ -0,0 +1,34 @@ +package com.perfact.be.domain.chat.converter; + +import com.perfact.be.domain.chat.exception.ChatHandler; +import com.perfact.be.domain.chat.exception.status.ChatErrorStatus; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +public class ChatMessageConverter { + + // AI 응답 메시지를 다듬어서 반환 + public String polishMessage(String rawMessage) { + if (rawMessage == null || rawMessage.trim().isEmpty()) { + throw new ChatHandler(ChatErrorStatus.CHAT_MESSAGE_PARSING_FAILED); + } + + try { + // 앞뒤 공백 제거 + String polished = rawMessage.trim() + .replaceAll("\n{3,}", "\n\n") + .replaceAll("([.!?])\n", "$1\n\n"); + log.debug("메시지 다듬기 완료 - 원본 길이: {}, 다듬은 길이: {}", + rawMessage.length(), polished.length()); + return polished; + + } catch (Exception e) { + log.error("메시지 다듬기 실패: {}", e.getMessage(), e); + throw new ChatHandler(ChatErrorStatus.CHAT_MESSAGE_PARSING_FAILED); + } + + } + +} \ No newline at end of file diff --git a/src/main/java/com/perfact/be/domain/chat/converter/ClovaApiConverter.java b/src/main/java/com/perfact/be/domain/chat/converter/ClovaApiConverter.java index ac1412a..6df67a6 100644 --- a/src/main/java/com/perfact/be/domain/chat/converter/ClovaApiConverter.java +++ b/src/main/java/com/perfact/be/domain/chat/converter/ClovaApiConverter.java @@ -1,151 +1,67 @@ package com.perfact.be.domain.chat.converter; -import com.fasterxml.jackson.databind.ObjectMapper; import com.perfact.be.domain.chat.dto.ClovaChatRequestDTO; -import com.perfact.be.domain.chat.dto.ClovaChatResponseDTO; -import com.perfact.be.domain.chat.dto.ClovaRecommendRequestDTO; -import com.perfact.be.domain.chat.dto.ClovaRecommendResponseDTO; -import com.perfact.be.domain.chat.exception.ChatHandler; -import com.perfact.be.domain.chat.exception.status.ChatErrorStatus; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.http.HttpEntity; -import org.springframework.http.HttpHeaders; -import org.springframework.http.HttpMethod; -import org.springframework.http.MediaType; -import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Component; -import org.springframework.web.client.RestTemplate; import java.util.ArrayList; import java.util.List; -@Slf4j + @Component -@RequiredArgsConstructor public class ClovaApiConverter { - private final RestTemplate restTemplate; - private final ObjectMapper objectMapper; - - @Value("${api.clova.chat-url}") - private String CLOVA_CHAT_URL; - - @Value("${api.clova.api-key}") - private String CLOVA_API_KEY; + private static final String SYSTEM_CHAT_PROMPT = "Important: The service name is 'Perfact' (P-e-r-f-a-c-t). This is the correct spelling, not 'Perfect'. You must always use the name 'Perfact' when referring to the service.\n\nYou are a friendly and helpful AI assistant for the 'Perfact' service. Your role is to answer user questions by referring to both the original news article (`기사 원문`) and the provided AI analysis report summary (`AI 분석 리포트 요약`). Use the original article as the primary source for facts and the analysis report for context about reliability. Do not make up information. If the answer is not in the provided context, say you don't know. Answer in Korean."; + private static final String SYSTEM_RECOMMEND_PROMPT = "You are an AI assistant that creates insightful recommended questions. Based on the provided analysis report summary, your goal is to generate two concise and relevant questions a user would most likely ask. The questions should highlight potential weaknesses or interesting points from the report (e.g., low scores, specific badges). Your final output MUST be a JSON array containing exactly two strings, like [\"질문 1\", \"질문 2\"]. Answer in Korean."; + private static final double DEFAULT_TOP_P = 0.8; + private static final int DEFAULT_TOP_K = 0; + private static final int CHAT_MAX_TOKENS = 512; + private static final int RECO_MAX_TOKENS = 100; + private static final double DEFAULT_TEMPERATURE = 0.5; + private static final double DEFAULT_REPEAT_PENALTY = 5.0; // 채팅 API 호출을 위한 요청을 생성 public ClovaChatRequestDTO createChatRequest(String chatbotContext, String articleContent, String userInput) { - List messages = new ArrayList<>(); - messages.add(ClovaChatRequestDTO.Message.builder() - .role("system") - .content( - "Important: The service name is 'Perfact' (P-e-r-f-a-c-t). This is the correct spelling, not 'Perfect'. You must always use the name 'Perfact' when referring to the service.\n\nYou are a friendly and helpful AI assistant for the 'Perfact' service. Your role is to answer user questions by referring to both the original news article (`기사 원문`) and the provided AI analysis report summary (`AI 분석 리포트 요약`). Use the original article as the primary source for facts and the analysis report for context about reliability. Do not make up information. If the answer is not in the provided context, say you don't know. Answer in Korean.") - .build()); - // 사용자 메시지 (기사 원문 + 분석 리포트 요약 + 질문) - String userContent = "[분석 대상 기사 원문]\n" + articleContent + "\n\n---\n\n[AI 분석 리포트 요약]\n" + chatbotContext + String userContent = "[분석 대상 기사 원문]\n" + articleContent + + "\n\n---\n\n[AI 분석 리포트 요약]\n" + chatbotContext + "\n\n이 내용을 기반으로 사용자의 질문에 상세히 답변하세요.\n\n---\n\n[사용자 질문]\n" + userInput; - messages.add(ClovaChatRequestDTO.Message.builder() - .role("user") - .content(userContent) - .build()); return ClovaChatRequestDTO.builder() - .messages(messages) - .topP(0.8) - .topK(0) - .maxTokens(512) - .temperature(0.5) - .repeatPenalty(5.0) + .messages( + List.of( + ClovaChatRequestDTO.Message.builder().role("system").content(SYSTEM_CHAT_PROMPT).build(), + ClovaChatRequestDTO.Message.builder().role("user").content(userContent).build() + )) + .topP(DEFAULT_TOP_P) + .topK(DEFAULT_TOP_K) + .maxTokens(CHAT_MAX_TOKENS) + .temperature(DEFAULT_TEMPERATURE) + .repeatPenalty(DEFAULT_REPEAT_PENALTY) .stopBefore(new ArrayList<>()) .includeAiFilters(true) .seed(0) .build(); + } // 추천 질문 API 호출을 위한 요청을 생성 - public ClovaRecommendRequestDTO createRecommendRequest(String chatbotContext) { - List messages = new ArrayList<>(); - - // 시스템 메시지 - messages.add(ClovaRecommendRequestDTO.Message.builder() - .role("system") - .content( - "You are an AI assistant that creates insightful recommended questions. Based on the provided analysis report summary, your goal is to generate two concise and relevant questions a user would most likely ask. The questions should highlight potential weaknesses or interesting points from the report (e.g., low scores, specific badges). Your final output MUST be a JSON array containing exactly two strings, like [\"질문 1\", \"질문 2\"]. Answer in Korean.") - .build()); - + public ClovaChatRequestDTO createRecommendRequest(String chatbotContext) { // 사용자 메시지 (리포트 컨텍스트) String userContent = chatbotContext + "\n\n[지시]\n이 분석 리포트에서 사용자가 가장 궁금해할 만한 핵심 질문 2개를 추천해 주세요."; - messages.add(ClovaRecommendRequestDTO.Message.builder() - .role("user") - .content(userContent) - .build()); - return ClovaRecommendRequestDTO.builder() - .messages(messages) - .topP(0.8) - .topK(0) - .maxTokens(100) - .temperature(0.5) - .repeatPenalty(5.0) + return ClovaChatRequestDTO.builder() + .messages(List.of( + ClovaChatRequestDTO.Message.builder().role("system").content(SYSTEM_RECOMMEND_PROMPT).build(), + ClovaChatRequestDTO.Message.builder().role("user").content(userContent).build())) + .topP(DEFAULT_TOP_P) + .topK(DEFAULT_TOP_K) + .maxTokens(RECO_MAX_TOKENS) + .temperature(DEFAULT_TEMPERATURE) + .repeatPenalty(DEFAULT_REPEAT_PENALTY) .stopBefore(new ArrayList<>()) .includeAiFilters(true) .seed(0) .build(); } - // Clova 채팅 API를 호출 - public ClovaChatResponseDTO callChatAPI(ClovaChatRequestDTO request) { - try { - log.info("Clova 채팅 API 호출"); - - HttpHeaders headers = new HttpHeaders(); - headers.setContentType(MediaType.APPLICATION_JSON); - headers.setBearerAuth(CLOVA_API_KEY); - - String requestBody = objectMapper.writeValueAsString(request); - HttpEntity entity = new HttpEntity<>(requestBody, headers); - - ResponseEntity response = restTemplate.exchange( - CLOVA_CHAT_URL, - HttpMethod.POST, - entity, - ClovaChatResponseDTO.class); - - log.info("Clova 채팅 API 응답 성공"); - return response.getBody(); - - } catch (Exception e) { - log.error("Clova 채팅 API 호출 실패: {}", e.getMessage(), e); - throw new ChatHandler(ChatErrorStatus.CHAT_API_CALL_FAILED); - } - } - - // Clova 추천 질문 API를 호출 - public ClovaRecommendResponseDTO callRecommendAPI(ClovaRecommendRequestDTO request) { - try { - log.info("Clova 추천 질문 API 호출"); - - HttpHeaders headers = new HttpHeaders(); - headers.setContentType(MediaType.APPLICATION_JSON); - headers.setBearerAuth(CLOVA_API_KEY); - - HttpEntity entity = new HttpEntity<>(request, headers); - - ResponseEntity response = restTemplate.exchange( - CLOVA_CHAT_URL, - HttpMethod.POST, - entity, - ClovaRecommendResponseDTO.class); - - log.info("Clova 추천 질문 API 응답 성공"); - return response.getBody(); - - } catch (Exception e) { - log.error("Clova 추천 질문 API 호출 실패: {}", e.getMessage(), e); - throw new ChatHandler(ChatErrorStatus.CHAT_API_CALL_FAILED); - } - } } \ No newline at end of file diff --git a/src/main/java/com/perfact/be/domain/chat/converter/JsonConverter.java b/src/main/java/com/perfact/be/domain/chat/converter/JsonConverter.java index b68ff29..f161ae2 100644 --- a/src/main/java/com/perfact/be/domain/chat/converter/JsonConverter.java +++ b/src/main/java/com/perfact/be/domain/chat/converter/JsonConverter.java @@ -16,20 +16,15 @@ public class JsonConverter { private final ObjectMapper objectMapper; - // JSON 배열 형태의 문자열을 List으로 변환 public List parseJsonArray(String content) { + // content가 null이거나 비어있는지 확인 + if (content == null || content.trim().isEmpty()) { + log.error("JSON 파싱할 content가 null이거나 비어있음"); + throw new ChatHandler(ChatErrorStatus.CHAT_MESSAGE_PARSING_FAILED); + } try { - log.debug("JSON 배열 파싱 시작 - content: {}", content); - - // content가 null이거나 비어있는지 확인 - if (content == null || content.trim().isEmpty()) { - log.error("JSON 파싱할 content가 null이거나 비어있음"); - throw new ChatHandler(ChatErrorStatus.CHAT_MESSAGE_PARSING_FAILED); - } - // JSON 배열 형태의 문자열을 List으로 변환 List result = objectMapper.readValue(content, List.class); - log.debug("JSON 배열 파싱 완료 - 결과 개수: {}, 결과: {}", result.size(), result); return result; @@ -37,5 +32,7 @@ public List parseJsonArray(String content) { log.error("JSON 배열 파싱 실패 - content: {}, 에러: {}", content, e.getMessage(), e); throw new ChatHandler(ChatErrorStatus.CHAT_MESSAGE_PARSING_FAILED); } + } + } \ No newline at end of file diff --git a/src/main/java/com/perfact/be/domain/chat/dto/ChatLogListResponseDTO.java b/src/main/java/com/perfact/be/domain/chat/dto/ChatLogListResponseDTO.java deleted file mode 100644 index 6998093..0000000 --- a/src/main/java/com/perfact/be/domain/chat/dto/ChatLogListResponseDTO.java +++ /dev/null @@ -1,45 +0,0 @@ -package com.perfact.be.domain.chat.dto; - -import com.perfact.be.domain.chat.entity.SenderType; -import io.swagger.v3.oas.annotations.media.Schema; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Getter; -import lombok.NoArgsConstructor; - -import java.time.LocalDateTime; -import java.util.List; - -@Getter -@Builder -@NoArgsConstructor -@AllArgsConstructor -@Schema(description = "채팅 로그 목록 응답 DTO") -public class ChatLogListResponseDTO { - - @Schema(description = "리포트 ID", example = "1") - private Long reportId; - - @Schema(description = "채팅 로그 목록") - private List chatLogs; - - @Getter - @Builder - @NoArgsConstructor - @AllArgsConstructor - @Schema(description = "채팅 로그 DTO") - public static class ChatLogDTO { - @Schema(description = "채팅 ID", example = "1") - private Long chatId; - - @Schema(description = "발신자 타입", example = "USER", allowableValues = { "USER", "AI" }) - private SenderType senderType; - - @Schema(description = "메시지 내용", example = "안녕하세요") - private String message; - - @Schema(description = "생성 시간", example = "2025-08-07T01:30:56") - private LocalDateTime createdAt; - } - -} \ No newline at end of file diff --git a/src/main/java/com/perfact/be/domain/chat/dto/ChatRequestDTO.java b/src/main/java/com/perfact/be/domain/chat/dto/ChatRequest.java similarity index 72% rename from src/main/java/com/perfact/be/domain/chat/dto/ChatRequestDTO.java rename to src/main/java/com/perfact/be/domain/chat/dto/ChatRequest.java index 5cde690..f11dc69 100644 --- a/src/main/java/com/perfact/be/domain/chat/dto/ChatRequestDTO.java +++ b/src/main/java/com/perfact/be/domain/chat/dto/ChatRequest.java @@ -1,6 +1,7 @@ package com.perfact.be.domain.chat.dto; import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; import lombok.AllArgsConstructor; import lombok.Getter; import lombok.NoArgsConstructor; @@ -9,8 +10,10 @@ @NoArgsConstructor @AllArgsConstructor @Schema(description = "채팅 요청 DTO") -public class ChatRequestDTO { +public class ChatRequest { + @NotBlank(message = "사용자 질문은 필수 입력값입니다.") @Schema(description = "사용자 질문", example = "왜 총점이 85점인가요?") private String userInput; + } \ No newline at end of file diff --git a/src/main/java/com/perfact/be/domain/chat/dto/ChatResponse.java b/src/main/java/com/perfact/be/domain/chat/dto/ChatResponse.java new file mode 100644 index 0000000..1fdd040 --- /dev/null +++ b/src/main/java/com/perfact/be/domain/chat/dto/ChatResponse.java @@ -0,0 +1,69 @@ +package com.perfact.be.domain.chat.dto; + +import com.perfact.be.domain.chat.entity.SenderType; +import io.swagger.v3.oas.annotations.media.Schema; +import java.time.LocalDateTime; +import java.util.List; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + + +@Schema(description = "챗봇 관련 응답 DTO") +public class ChatResponse { + + @Getter + @Builder + @NoArgsConstructor + @AllArgsConstructor + @Schema(description = "채팅 응답 DTO") + public static class ChatResponseDTO { + @Schema(description = "AI 응답 메시지", + example = "총점이 85점인 이유는 세부 평가 근거에서 각 항목별로 받은 점수를 합산했기 때문입니다.") + private String aiResponse; + } + + @Getter + @Builder + @NoArgsConstructor + @AllArgsConstructor + @Schema(description = "추천 질문 응답 DTO") + public static class RecommendQuestionsResponseDTO { + @Schema(description = "추천 질문 목록", example = "[\"해당 기사의 출처인 언론사가 어디인지 알 수 있을까요?\", \"기업의 전략적 배경과 의도를 자세하게 설명했는데, 이 부분이 편향성 측면에서 어떤 영향을 미쳤다고 생각하시나요?\"]") + private List questions; + } + + @Getter + @Builder + @NoArgsConstructor + @AllArgsConstructor + @Schema(description = "채팅 로그 목록 응답 DTO") + public static class ChatLogListResponseDTO { + @Schema(description = "리포트 ID", example = "1") + private Long reportId; + + @Schema(description = "채팅 로그 목록") + private List chatLogs; + } + + @Getter + @Builder + @NoArgsConstructor + @AllArgsConstructor + @Schema(description = "채팅 로그 DTO") + public static class ChatLogDTO { + @Schema(description = "채팅 ID", example = "1") + private Long chatId; + + @Schema(description = "발신자 타입", example = "USER", allowableValues = {"USER", "AI"}) + private SenderType senderType; + + @Schema(description = "메시지 내용", example = "안녕하세요") + private String message; + + @Schema(description = "생성 시간", example = "2025-08-07T01:30:56") + private LocalDateTime createdAt; + } + +} \ No newline at end of file diff --git a/src/main/java/com/perfact/be/domain/chat/dto/ChatResponseDTO.java b/src/main/java/com/perfact/be/domain/chat/dto/ChatResponseDTO.java deleted file mode 100644 index 628ae88..0000000 --- a/src/main/java/com/perfact/be/domain/chat/dto/ChatResponseDTO.java +++ /dev/null @@ -1,18 +0,0 @@ -package com.perfact.be.domain.chat.dto; - -import io.swagger.v3.oas.annotations.media.Schema; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Getter; -import lombok.NoArgsConstructor; - -@Getter -@Builder -@NoArgsConstructor -@AllArgsConstructor -@Schema(description = "채팅 응답 DTO") -public class ChatResponseDTO { - - @Schema(description = "AI 응답 메시지", example = "총점이 85점인 이유는 세부 평가 근거에서 각 항목별로 받은 점수를 합산했기 때문입니다.") - private String aiResponse; -} \ No newline at end of file diff --git a/src/main/java/com/perfact/be/domain/chat/dto/ClovaChatRequestDTO.java b/src/main/java/com/perfact/be/domain/chat/dto/ClovaChatRequestDTO.java index 708dd3a..55e1953 100644 --- a/src/main/java/com/perfact/be/domain/chat/dto/ClovaChatRequestDTO.java +++ b/src/main/java/com/perfact/be/domain/chat/dto/ClovaChatRequestDTO.java @@ -12,7 +12,7 @@ @Builder @NoArgsConstructor @AllArgsConstructor -@Schema(description = "Clova 채팅 API 요청 DTO") +@Schema(description = "Clova 공통 요청 DTO") public class ClovaChatRequestDTO { private List messages; diff --git a/src/main/java/com/perfact/be/domain/chat/dto/ClovaChatResponseDTO.java b/src/main/java/com/perfact/be/domain/chat/dto/ClovaChatResponseDTO.java index a08a38c..0194967 100644 --- a/src/main/java/com/perfact/be/domain/chat/dto/ClovaChatResponseDTO.java +++ b/src/main/java/com/perfact/be/domain/chat/dto/ClovaChatResponseDTO.java @@ -8,7 +8,7 @@ @Getter @NoArgsConstructor @AllArgsConstructor -@Schema(description = "Clova 채팅 API 응답 DTO") +@Schema(description = "Clova 공통 응답 DTO") public class ClovaChatResponseDTO { private Status status; diff --git a/src/main/java/com/perfact/be/domain/chat/dto/ClovaRecommendRequestDTO.java b/src/main/java/com/perfact/be/domain/chat/dto/ClovaRecommendRequestDTO.java deleted file mode 100644 index 9868281..0000000 --- a/src/main/java/com/perfact/be/domain/chat/dto/ClovaRecommendRequestDTO.java +++ /dev/null @@ -1,36 +0,0 @@ -package com.perfact.be.domain.chat.dto; - -import io.swagger.v3.oas.annotations.media.Schema; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Getter; -import lombok.NoArgsConstructor; - -import java.util.List; - -@Getter -@Builder -@NoArgsConstructor -@AllArgsConstructor -@Schema(description = "Clova 추천 질문 API 요청 DTO") -public class ClovaRecommendRequestDTO { - - private List messages; - private double topP; - private int topK; - private int maxTokens; - private double temperature; - private double repeatPenalty; - private List stopBefore; - private boolean includeAiFilters; - private int seed; - - @Getter - @Builder - @NoArgsConstructor - @AllArgsConstructor - public static class Message { - private String role; - private String content; - } -} \ No newline at end of file diff --git a/src/main/java/com/perfact/be/domain/chat/dto/ClovaRecommendResponseDTO.java b/src/main/java/com/perfact/be/domain/chat/dto/ClovaRecommendResponseDTO.java deleted file mode 100644 index 715f28e..0000000 --- a/src/main/java/com/perfact/be/domain/chat/dto/ClovaRecommendResponseDTO.java +++ /dev/null @@ -1,43 +0,0 @@ -package com.perfact.be.domain.chat.dto; - -import io.swagger.v3.oas.annotations.media.Schema; -import lombok.AllArgsConstructor; -import lombok.Getter; -import lombok.NoArgsConstructor; - -@Getter -@NoArgsConstructor -@AllArgsConstructor -@Schema(description = "Clova 추천 질문 API 응답 DTO") -public class ClovaRecommendResponseDTO { - - private Status status; - private Result result; - - @Getter - @NoArgsConstructor - @AllArgsConstructor - public static class Status { - private String code; - private String message; - } - - @Getter - @NoArgsConstructor - @AllArgsConstructor - public static class Result { - private Message message; - private int inputLength; - private int outputLength; - private String stopReason; - private Long seed; - } - - @Getter - @NoArgsConstructor - @AllArgsConstructor - public static class Message { - private String role; - private String content; - } -} \ No newline at end of file diff --git a/src/main/java/com/perfact/be/domain/chat/dto/RecommendQuestionsResponseDTO.java b/src/main/java/com/perfact/be/domain/chat/dto/RecommendQuestionsResponseDTO.java deleted file mode 100644 index 0bb9041..0000000 --- a/src/main/java/com/perfact/be/domain/chat/dto/RecommendQuestionsResponseDTO.java +++ /dev/null @@ -1,20 +0,0 @@ -package com.perfact.be.domain.chat.dto; - -import io.swagger.v3.oas.annotations.media.Schema; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Getter; -import lombok.NoArgsConstructor; - -import java.util.List; - -@Getter -@Builder -@NoArgsConstructor -@AllArgsConstructor -@Schema(description = "추천 질문 응답 DTO") -public class RecommendQuestionsResponseDTO { - - @Schema(description = "추천 질문 목록", example = "[\"해당 기사의 출처인 언론사가 어디인지 알 수 있을까요?\", \"기업의 전략적 배경과 의도를 자세하게 설명했는데, 이 부분이 편향성 측면에서 어떤 영향을 미쳤다고 생각하시나요?\"]") - private List questions; -} \ No newline at end of file diff --git a/src/main/java/com/perfact/be/domain/chat/exception/status/ChatErrorStatus.java b/src/main/java/com/perfact/be/domain/chat/exception/status/ChatErrorStatus.java index b577854..2553f48 100644 --- a/src/main/java/com/perfact/be/domain/chat/exception/status/ChatErrorStatus.java +++ b/src/main/java/com/perfact/be/domain/chat/exception/status/ChatErrorStatus.java @@ -13,8 +13,9 @@ public enum ChatErrorStatus implements BaseErrorCode { CHAT_API_CALL_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "CHAT4001", "채팅 API 호출에 실패했습니다."), CHAT_MESSAGE_PARSING_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "CHAT4002", "채팅 메시지 파싱에 실패했습니다."), CHAT_LOG_SAVE_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "CHAT4003", "채팅 로그 저장에 실패했습니다."), - CHAT_REPORT_NOT_FOUND(HttpStatus.NOT_FOUND, "CHAT4004", "해당 리포트를 찾을 수 없습니다."); + CHAT_REPORT_NOT_FOUND(HttpStatus.NOT_FOUND, "CHAT4004", "해당 리포트를 찾을 수 없습니다."), + ; private final HttpStatus httpStatus; private final String code; private final String message; diff --git a/src/main/java/com/perfact/be/domain/chat/infrastructure/ClovaApiClient.java b/src/main/java/com/perfact/be/domain/chat/infrastructure/ClovaApiClient.java new file mode 100644 index 0000000..64b7acf --- /dev/null +++ b/src/main/java/com/perfact/be/domain/chat/infrastructure/ClovaApiClient.java @@ -0,0 +1,81 @@ +package com.perfact.be.domain.chat.infrastructure; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.perfact.be.domain.chat.dto.ClovaChatRequestDTO; +import com.perfact.be.domain.chat.dto.ClovaChatResponseDTO; +import com.perfact.be.domain.chat.exception.ChatHandler; +import com.perfact.be.domain.chat.exception.status.ChatErrorStatus; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClientResponseException; +import org.springframework.web.client.RestTemplate; + +@Slf4j +@Component +@RequiredArgsConstructor +public class ClovaApiClient { + + private final RestTemplate restTemplate; + private final ObjectMapper objectMapper; + + @Value("${api.clova.chat-url}") + private String CLOVA_CHAT_URL; + + @Value("${api.clova.api-key}") + private String CLOVA_API_KEY; + + + // Clova 채팅 API를 호출 + public ClovaChatResponseDTO callChatAPI(ClovaChatRequestDTO request) { + return post(CLOVA_CHAT_URL, request, ClovaChatResponseDTO.class, "Clova 채팅"); + } + + // Clova 추천 질문 API를 호출 + public ClovaChatResponseDTO callRecommendAPI(ClovaChatRequestDTO request) { + return post(CLOVA_CHAT_URL, request, ClovaChatResponseDTO.class, "Clova 추천"); + } + + private TRes post(String url, TReq body, Class resType, String logName) { + try { + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + headers.setBearerAuth(CLOVA_API_KEY); + + String json = serialize(body); + HttpEntity entity = new HttpEntity<>(json, headers); + + log.info("{} API 호출 → {}", logName, url); + ResponseEntity response = restTemplate.exchange(url, HttpMethod.POST, entity, resType); + + if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null) { + log.debug("{} API 응답 수신 (status: {})", logName, response.getStatusCode()); + return response.getBody(); + } + log.error("{} API 비정상 응답 (status: {}, body=null)", logName, response.getStatusCode()); + throw new ChatHandler(ChatErrorStatus.CHAT_API_CALL_FAILED); + + }catch (RestClientResponseException e) { + log.error("{} API 실패 (status:{}, body:{})", logName, e.getRawStatusCode(), e.getResponseBodyAsString(), e); + throw new ChatHandler(ChatErrorStatus.CHAT_API_CALL_FAILED); + } catch (Exception e) { + log.error("{} API 호출 중 예외", logName, e); + throw new ChatHandler(ChatErrorStatus.CHAT_API_CALL_FAILED); + } + + } + + private String serialize(Object body) throws JsonProcessingException { + return objectMapper.writeValueAsString(body); + } + + +} diff --git a/src/main/java/com/perfact/be/domain/chat/service/ChatService.java b/src/main/java/com/perfact/be/domain/chat/service/ChatService.java index a27a0c9..c29d8fd 100644 --- a/src/main/java/com/perfact/be/domain/chat/service/ChatService.java +++ b/src/main/java/com/perfact/be/domain/chat/service/ChatService.java @@ -1,14 +1,13 @@ package com.perfact.be.domain.chat.service; -import com.perfact.be.domain.chat.dto.ChatLogListResponseDTO; -import com.perfact.be.domain.chat.dto.ChatRequestDTO; -import com.perfact.be.domain.chat.dto.ChatResponseDTO; -import com.perfact.be.domain.chat.dto.RecommendQuestionsResponseDTO; +import com.perfact.be.domain.chat.dto.ChatRequest; +import com.perfact.be.domain.chat.dto.ChatResponse; + public interface ChatService { - ChatResponseDTO sendMessage(Long reportId, ChatRequestDTO request); + ChatResponse.ChatResponseDTO sendMessage(Long reportId, ChatRequest request); - ChatLogListResponseDTO getChatLogs(Long reportId); + ChatResponse.ChatLogListResponseDTO getChatLogs(Long reportId); - RecommendQuestionsResponseDTO getRecommendQuestions(Long reportId); + ChatResponse.RecommendQuestionsResponseDTO getRecommendQuestions(Long reportId); } \ No newline at end of file diff --git a/src/main/java/com/perfact/be/domain/chat/service/ChatServiceImpl.java b/src/main/java/com/perfact/be/domain/chat/service/ChatServiceImpl.java index 2925991..f52705e 100644 --- a/src/main/java/com/perfact/be/domain/chat/service/ChatServiceImpl.java +++ b/src/main/java/com/perfact/be/domain/chat/service/ChatServiceImpl.java @@ -1,20 +1,17 @@ package com.perfact.be.domain.chat.service; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; import com.perfact.be.domain.chat.converter.ChatLogConverter; import com.perfact.be.domain.chat.converter.ClovaApiConverter; import com.perfact.be.domain.chat.converter.JsonConverter; -import com.perfact.be.domain.chat.dto.ChatLogListResponseDTO; -import com.perfact.be.domain.chat.dto.ChatRequestDTO; -import com.perfact.be.domain.chat.dto.ChatResponseDTO; -import com.perfact.be.domain.chat.dto.RecommendQuestionsResponseDTO; +import com.perfact.be.domain.chat.dto.ChatRequest; +import com.perfact.be.domain.chat.dto.ChatResponse; import com.perfact.be.domain.chat.entity.ChatLog; import com.perfact.be.domain.chat.entity.SenderType; import com.perfact.be.domain.chat.exception.ChatHandler; import com.perfact.be.domain.chat.exception.status.ChatErrorStatus; +import com.perfact.be.domain.chat.infrastructure.ClovaApiClient; import com.perfact.be.domain.chat.repository.ChatLogRepository; -import com.perfact.be.domain.chat.util.ChatMessageUtil; +import com.perfact.be.domain.chat.converter.ChatMessageConverter; import com.perfact.be.domain.report.entity.Report; import com.perfact.be.domain.report.repository.ReportRepository; import lombok.RequiredArgsConstructor; @@ -31,111 +28,112 @@ public class ChatServiceImpl implements ChatService { private final ChatLogRepository chatLogRepository; private final ReportRepository reportRepository; - private final ChatMessageUtil chatMessageUtil; + private final ClovaApiClient clovaApiClient; + + private final ChatMessageConverter chatMessageConverter; private final ClovaApiConverter clovaApiConverter; private final JsonConverter jsonConverter; private final ChatLogConverter chatLogConverter; @Override @Transactional - public ChatResponseDTO sendMessage(Long reportId, ChatRequestDTO request) { - try { - log.info("채팅 메시지 요청 - reportId: {}, userInput: {}", reportId, request.getUserInput()); - - // 1. 리포트 조회 - Report report = reportRepository.findById(reportId) - .orElseThrow(() -> new ChatHandler(ChatErrorStatus.CHAT_REPORT_NOT_FOUND)); - - // 2. 사용자 메시지를 ChatLog에 저장 - ChatLog userChatLog = ChatLog.builder() - .senderType(SenderType.USER) - .message(request.getUserInput()) - .reportId(report.getReportId()) - .build(); - chatLogRepository.save(userChatLog); - - // 3. Clova API 호출을 위한 요청 생성 - var clovaRequest = clovaApiConverter.createChatRequest(report.getChatbotContext(), report.getArticleContent(), - request.getUserInput()); - - // 4. Clova API 호출 - var clovaResponse = clovaApiConverter.callChatAPI(clovaRequest); - - // 5. AI 응답 메시지 다듬기 - String polishedResponse = chatMessageUtil.polishMessage(clovaResponse.getResult().getMessage().getContent()); + public ChatResponse.ChatResponseDTO sendMessage(Long reportId, ChatRequest request) { + log.info("채팅 메시지 요청 - reportId: {}, userInput: {}", reportId, request.getUserInput()); + // 1. 레포트 조회 + Report report = reportRepository.findById(reportId).orElseThrow(() -> new ChatHandler(ChatErrorStatus.CHAT_REPORT_NOT_FOUND)); + + // 2. 사용자 메시지를 ChatLog에 저장 + ChatLog userChatLog = ChatLog.builder() + .senderType(SenderType.USER) + .message(request.getUserInput()) + .reportId(report.getReportId()) + .build(); + chatLogRepository.save(userChatLog); + + // 3. Clova API 호출을 위한 요청 생성 + var clovaRequest = clovaApiConverter.createChatRequest(report.getChatbotContext(), report.getArticleContent(), request.getUserInput()); + String polishedResponse; - // 6. AI 응답을 ChatLog에 저장 - ChatLog aiChatLog = ChatLog.builder() - .senderType(SenderType.AI) - .message(polishedResponse) - .reportId(report.getReportId()) - .build(); - chatLogRepository.save(aiChatLog); + try { + // 4. Clova API 호출, NPE 가드, 응답 메세지 다듬기 + var clovaResponse = clovaApiClient.callChatAPI(clovaRequest); + + if (clovaResponse == null + || clovaResponse.getResult() == null + || clovaResponse.getResult().getMessage() == null) { + log.error("Clova 응답 포맷 오류 - null 필드 존재, reportId: {}", reportId); + throw new ChatHandler(ChatErrorStatus.CHAT_API_CALL_FAILED); + } + + polishedResponse = chatMessageConverter.polishMessage( + clovaResponse.getResult().getMessage().getContent()); + } catch(Exception e){ + log.error("Clova API 처리 실패 - reportId: {}, 에러: {}", reportId, e.getMessage(), e); + throw new ChatHandler(ChatErrorStatus.CHAT_API_CALL_FAILED); + } - log.info("채팅 메시지 처리 완료 - reportId: {}, AI 응답 길이: {}", - reportId, polishedResponse.length()); + // 6. AI 응답을 ChatLog에 저장 + ChatLog aiChatLog = ChatLog.builder() + .senderType(SenderType.AI) + .message(polishedResponse) + .reportId(report.getReportId()) + .build(); + chatLogRepository.save(aiChatLog); + log.info("채팅 메시지 처리 완료 - reportId: {}, AI 응답 길이: {}", reportId, polishedResponse.length()); - return ChatResponseDTO.builder() - .aiResponse(polishedResponse) - .build(); + return chatLogConverter.toCharResponseDto(polishedResponse); - } catch (Exception e) { - log.error("채팅 메시지 처리 실패 - reportId: {}, 에러: {}", reportId, e.getMessage(), e); - throw new ChatHandler(ChatErrorStatus.CHAT_API_CALL_FAILED); - } } @Override @Transactional(readOnly = true) - public ChatLogListResponseDTO getChatLogs(Long reportId) { - try { - log.info("채팅 로그 조회 요청 - reportId: {}", reportId); - - // 1. 리포트 존재 여부 확인 - Report report = reportRepository.findById(reportId) - .orElseThrow(() -> new ChatHandler(ChatErrorStatus.CHAT_REPORT_NOT_FOUND)); - - // 2. 채팅 로그 조회 (시간 순으로 오름차순) - List chatLogs = chatLogRepository.findByReportIdOrderByCreatedAtAsc(reportId); - - log.info("채팅 로그 조회 완료 - reportId: {}, 로그 개수: {}", reportId, chatLogs.size()); + public ChatResponse.ChatLogListResponseDTO getChatLogs(Long reportId) { + // 1. 리포트 존재 여부 확인 + if (!reportRepository.existsById(reportId)) { + throw new ChatHandler(ChatErrorStatus.CHAT_REPORT_NOT_FOUND); + } + // 2. 채팅 로그 조회 (시간 순으로 오름차순) + List chatLogs = chatLogRepository.findByReportIdOrderByCreatedAtAsc(reportId); + log.info("채팅 로그 조회 완료 - reportId: {}, 로그 개수: {}", reportId, chatLogs.size()); - return chatLogConverter.toResponseDto(reportId, chatLogs); + return chatLogConverter.toResponseDto(reportId, chatLogs); - } catch (Exception e) { - log.error("채팅 로그 조회 실패 - reportId: {}, 에러: {}", reportId, e.getMessage(), e); - throw new ChatHandler(ChatErrorStatus.CHAT_API_CALL_FAILED); - } } @Override @Transactional - public RecommendQuestionsResponseDTO getRecommendQuestions(Long reportId) { - try { - log.info("추천 질문 요청 - reportId: {}", reportId); - - // 1. 리포트 조회 - Report report = reportRepository.findById(reportId) - .orElseThrow(() -> new ChatHandler(ChatErrorStatus.CHAT_REPORT_NOT_FOUND)); + public ChatResponse.RecommendQuestionsResponseDTO getRecommendQuestions(Long reportId) { + // 1. 리포트 조회 + Report report = reportRepository.findById(reportId) + .orElseThrow(() -> new ChatHandler(ChatErrorStatus.CHAT_REPORT_NOT_FOUND)); - // 2. 추천 질문 요청 생성 - var recommendRequest = clovaApiConverter.createRecommendRequest(report.getChatbotContext()); + // 2. 추천 질문 요청 생성 + var recommendRequest = clovaApiConverter.createRecommendRequest(report.getChatbotContext()); + List questions; - // 3. Clova API 호출 - var recommendResponse = clovaApiConverter.callRecommendAPI(recommendRequest); - - // 4. 응답에서 질문 목록 추출 - List questions = jsonConverter.parseJsonArray(recommendResponse.getResult().getMessage().getContent()); + // 3. Clova API 호출, NPE 가드, 응답에서 질문 목록 추출 + try { + var recommendResponse = clovaApiClient.callRecommendAPI(recommendRequest); - log.info("추천 질문 생성 완료 - reportId: {}, 질문 개수: {}", reportId, questions.size()); + if (recommendResponse == null + || recommendResponse.getResult() == null + || recommendResponse.getResult().getMessage() == null + || recommendResponse.getResult().getMessage().getContent() == null) { + log.error("Clova 추천 응답 포맷 오류 - null 필드 존재, reportId: {}", reportId); + throw new ChatHandler(ChatErrorStatus.CHAT_API_CALL_FAILED); + } - return RecommendQuestionsResponseDTO.builder() - .questions(questions) - .build(); + String content = recommendResponse.getResult().getMessage().getContent(); + questions = jsonConverter.parseJsonArray(content); } catch (Exception e) { log.error("추천 질문 생성 실패 - reportId: {}, 에러: {}", reportId, e.getMessage(), e); throw new ChatHandler(ChatErrorStatus.CHAT_API_CALL_FAILED); } + + log.info("추천 질문 생성 완료 - reportId: {}, 질문 개수: {}", reportId, questions.size()); + + return chatLogConverter.toRecommendQuestionsResponseDto(questions); + } } \ No newline at end of file diff --git a/src/main/java/com/perfact/be/domain/chat/util/ChatMessageUtil.java b/src/main/java/com/perfact/be/domain/chat/util/ChatMessageUtil.java deleted file mode 100644 index 5d6093d..0000000 --- a/src/main/java/com/perfact/be/domain/chat/util/ChatMessageUtil.java +++ /dev/null @@ -1,35 +0,0 @@ -package com.perfact.be.domain.chat.util; - -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Component; - -@Slf4j -@Component -public class ChatMessageUtil { - - // AI 응답 메시지를 다듬어서 반환 - public String polishMessage(String rawMessage) { - try { - if (rawMessage == null || rawMessage.trim().isEmpty()) { - return "응답을 생성할 수 없습니다."; - } - - // 앞뒤 공백 제거 - String polished = rawMessage.trim(); - - // 연속된 개행 문자를 하나로 통일 - polished = polished.replaceAll("\n{3,}", "\n\n"); - - // 문장 끝에 개행 추가하여 가독성 향상 - polished = polished.replaceAll("([.!?])\n", "$1\n\n"); - - log.debug("메시지 다듬기 완료 - 원본 길이: {}, 다듬은 길이: {}", - rawMessage.length(), polished.length()); - - return polished; - } catch (Exception e) { - log.error("메시지 다듬기 실패: {}", e.getMessage(), e); - return rawMessage != null ? rawMessage : "응답을 처리할 수 없습니다."; - } - } -} \ No newline at end of file