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
@@ -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<ChatResponseDTO> sendMessage(
public ApiResponse<ChatResponse.ChatResponseDTO> 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<ChatLogListResponseDTO> getChatLogs(
public ApiResponse<ChatResponse.ChatLogListResponseDTO> 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<RecommendQuestionsResponseDTO> getRecommendQuestions(
public ApiResponse<ChatResponse.RecommendQuestionsResponseDTO> 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);
}

}
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -11,31 +11,42 @@
@Slf4j
@Component
public class ChatLogConverter {

// ChatLog 엔티티를 ChatLogListResponseDTO로 변환
public ChatLogListResponseDTO toResponseDto(Long reportId, List<ChatLog> chatLogs) {
log.debug("ChatLog 변환 시작 - reportId: {}, chatLogs 개수: {}", reportId, chatLogs.size());

List<ChatLogListResponseDTO.ChatLogDTO> chatLogDTOs = chatLogs.stream()
// reportId, List<ChatLong> -> ChatLogListResponseDTO
public ChatResponse.ChatLogListResponseDTO toResponseDto(Long reportId, List<ChatLog> chatLogs) {
List<ChatResponse.ChatLogDTO> 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<String> -> RecommendQuestionsResponseDTO
public ChatResponse.RecommendQuestionsResponseDTO toRecommendQuestionsResponseDto(List<String> questions) {
return ChatResponse.RecommendQuestionsResponseDTO.builder()
.questions(questions)
.build();
}

}
Original file line number Diff line number Diff line change
@@ -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);
}

}

}
Original file line number Diff line number Diff line change
@@ -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<ClovaChatRequestDTO.Message> 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<ClovaRecommendRequestDTO.Message> 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<String> entity = new HttpEntity<>(requestBody, headers);

ResponseEntity<ClovaChatResponseDTO> 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<ClovaRecommendRequestDTO> entity = new HttpEntity<>(request, headers);

ResponseEntity<ClovaRecommendResponseDTO> 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);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,26 +16,23 @@ public class JsonConverter {

private final ObjectMapper objectMapper;

// JSON 배열 형태의 문자열을 List<String>으로 변환
public List<String> 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<String>으로 변환
List<String> result = objectMapper.readValue(content, List.class);

log.debug("JSON 배열 파싱 완료 - 결과 개수: {}, 결과: {}", result.size(), result);
return result;

} catch (Exception e) {
log.error("JSON 배열 파싱 실패 - content: {}, 에러: {}", content, e.getMessage(), e);
throw new ChatHandler(ChatErrorStatus.CHAT_MESSAGE_PARSING_FAILED);
}

}

}
Loading