diff --git a/backend/src/main/java/com/ai/lawyer/BackendApplication.java b/backend/src/main/java/com/ai/lawyer/BackendApplication.java index f35a28fd..89bfdf4a 100644 --- a/backend/src/main/java/com/ai/lawyer/BackendApplication.java +++ b/backend/src/main/java/com/ai/lawyer/BackendApplication.java @@ -4,7 +4,9 @@ import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.context.properties.ConfigurationPropertiesScan; import org.springframework.data.jpa.repository.config.EnableJpaAuditing; +import org.springframework.scheduling.annotation.EnableAsync; +@EnableAsync @SpringBootApplication @EnableJpaAuditing @ConfigurationPropertiesScan diff --git a/backend/src/main/java/com/ai/lawyer/domain/chatbot/controller/HistoryController.java b/backend/src/main/java/com/ai/lawyer/domain/chatbot/controller/HistoryController.java index a18190ed..557f528f 100644 --- a/backend/src/main/java/com/ai/lawyer/domain/chatbot/controller/HistoryController.java +++ b/backend/src/main/java/com/ai/lawyer/domain/chatbot/controller/HistoryController.java @@ -2,7 +2,6 @@ import com.ai.lawyer.domain.chatbot.dto.ChatDto.ChatHistoryDto; import com.ai.lawyer.domain.chatbot.dto.HistoryDto; -import com.ai.lawyer.domain.chatbot.service.ChatService; import com.ai.lawyer.domain.chatbot.service.HistoryService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; @@ -21,7 +20,6 @@ public class HistoryController { private final HistoryService historyService; - private final ChatService chatService; @Operation(summary = "채팅방 제목 목록 조회") @GetMapping("/") @@ -32,7 +30,7 @@ public ResponseEntity> getHistoryTitles(@AuthenticationPrincipa @Operation(summary = "채팅 조회") @GetMapping("/{historyId}") public ResponseEntity> getChatHistory(@AuthenticationPrincipal Long memberId, @PathVariable("historyId") Long roomId) { - return chatService.getChatHistory(memberId, roomId); + return historyService.getChatHistory(memberId, roomId); } @Operation(summary = "채팅방 삭제") diff --git a/backend/src/main/java/com/ai/lawyer/domain/chatbot/dto/ChatDto.java b/backend/src/main/java/com/ai/lawyer/domain/chatbot/dto/ChatDto.java index 93095e2c..16c21043 100644 --- a/backend/src/main/java/com/ai/lawyer/domain/chatbot/dto/ChatDto.java +++ b/backend/src/main/java/com/ai/lawyer/domain/chatbot/dto/ChatDto.java @@ -34,9 +34,6 @@ public static class ChatResponse { @Schema(description = "채팅방 ID", example = "1") private Long roomId; - @Schema(description = "History 방 제목", example = "손해배상 청구 관련 문의") - private String title; - @Schema(description = "AI 챗봇의 응답 메시지", example = "네, 관련 법령과 판례를 바탕으로 답변해 드리겠습니다.") private String message; diff --git a/backend/src/main/java/com/ai/lawyer/domain/chatbot/service/AsyncPostChatProcessingService.java b/backend/src/main/java/com/ai/lawyer/domain/chatbot/service/AsyncPostChatProcessingService.java new file mode 100644 index 00000000..419371a0 --- /dev/null +++ b/backend/src/main/java/com/ai/lawyer/domain/chatbot/service/AsyncPostChatProcessingService.java @@ -0,0 +1,132 @@ +package com.ai.lawyer.domain.chatbot.service; + +import com.ai.lawyer.domain.chatbot.dto.ExtractionDto.KeywordExtractionDto; +import com.ai.lawyer.domain.chatbot.dto.ExtractionDto.TitleExtractionDto; +import com.ai.lawyer.domain.chatbot.entity.*; +import com.ai.lawyer.domain.chatbot.repository.*; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.ai.chat.memory.ChatMemory; +import org.springframework.ai.chat.memory.ChatMemoryRepository; +import org.springframework.ai.chat.memory.MessageWindowChatMemory; +import org.springframework.ai.chat.messages.AssistantMessage; +import org.springframework.ai.chat.messages.MessageType; +import org.springframework.ai.document.Document; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.stream.Collectors; + +@Slf4j +@Service +@RequiredArgsConstructor +public class AsyncPostChatProcessingService { + + private final KeywordService keywordService; + private final HistoryRepository historyRepository; + private final ChatRepository chatRepository; + private final KeywordRankRepository keywordRankRepository; + private final ChatMemoryRepository chatMemoryRepository; + private final ChatPrecedentRepository chatPrecedentRepository; + private final ChatLawRepository chatLawRepository; + + @Value("${custom.ai.title-extraction}") + private String titleExtraction; + @Value("{$custom.ai.keyword-extraction}") + private String keywordExtraction; + + @Async + @Transactional + public void processHandlerTasks(Long historyId, String userMessage, String fullResponse, List similarCaseDocuments, List similarLawDocuments) { + try { + History history = historyRepository.findById(historyId) + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 채팅방입니다. historyId: " + historyId)); + + // 1. 메시지 기억 저장 + ChatMemory chatMemory = MessageWindowChatMemory.builder() + .maxMessages(10) + .chatMemoryRepository(chatMemoryRepository) + .build(); + + chatMemory.add(String.valueOf(history.getHistoryId()), new AssistantMessage(fullResponse)); + chatMemoryRepository.saveAll(String.valueOf(history.getHistoryId()), chatMemory.get(String.valueOf(history.getHistoryId()))); + + // 2. 채팅방 제목 설정 / 및 필터 + setHistoryTitle(userMessage, history, fullResponse); + + // 3. 채팅 기록 저장 + saveChatWithDocuments(history, MessageType.USER, userMessage, similarCaseDocuments, similarLawDocuments); + saveChatWithDocuments(history, MessageType.ASSISTANT, fullResponse, similarCaseDocuments, similarLawDocuments); + + // 4. 키워드 추출 및 랭킹 업데이트 + if (!fullResponse.contains("해당 질문은 법률")) { + extractAndUpdateKeywordRanks(userMessage); + } + } catch (Exception e) { + log.error("에러 발생: {}", historyId, e); + } + } + + private void setHistoryTitle(String userMessage, History history, String fullResponse) { + String targetText = fullResponse.contains("해당 질문은 법률") ? userMessage : fullResponse; + TitleExtractionDto titleDto = keywordService.keywordExtract(targetText, titleExtraction, TitleExtractionDto.class); + history.setTitle(titleDto.getTitle()); + historyRepository.save(history); // @Transactional 어노테이션으로 인해 메소드 종료 시 자동 저장되지만, 명시적으로 호출할 수도 있습니다. + } + + private void extractAndUpdateKeywordRanks(String message) { + KeywordExtractionDto keywordResponse = keywordService.keywordExtract(message, keywordExtraction, KeywordExtractionDto.class); + if (keywordResponse == null || keywordResponse.getKeyword() == null) { + return; + } + + KeywordRank keywordRank = keywordRankRepository.findByKeyword(keywordResponse.getKeyword()); + + if (keywordRank == null) { + keywordRank = KeywordRank.builder() + .keyword(keywordResponse.getKeyword()) + .score(1L) + .build(); + } else { + keywordRank.setScore(keywordRank.getScore() + 1); + } + keywordRankRepository.save(keywordRank); + } + + private void saveChatWithDocuments(History history, MessageType type, String message, List similarCaseDocuments, List similarLawDocuments) { + Chat chat = chatRepository.save(Chat.builder() + .historyId(history) + .type(type) + .message(message) + .build()); + + // Ai 메시지가 저장될 때 관련 문서 저장 + if (type == MessageType.ASSISTANT) { + if (similarCaseDocuments != null && !similarCaseDocuments.isEmpty()) { + List chatPrecedents = similarCaseDocuments.stream() + .map(doc -> ChatPrecedent.builder() + .chatId(chat) + .precedentContent(doc.getText()) + .caseNumber(doc.getMetadata().get("caseNumber").toString()) + .caseName(doc.getMetadata().get("caseName").toString()) + .build()) + .collect(Collectors.toList()); + chatPrecedentRepository.saveAll(chatPrecedents); + } + + if (similarLawDocuments != null && !similarLawDocuments.isEmpty()) { + List chatLaws = similarLawDocuments.stream() + .map(doc -> ChatLaw.builder() + .chatId(chat) + .content(doc.getText()) + .lawName(doc.getMetadata().get("lawName").toString()) + .build()) + .collect(Collectors.toList()); + chatLawRepository.saveAll(chatLaws); + } + } + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/ai/lawyer/domain/chatbot/service/ChatBotService.java b/backend/src/main/java/com/ai/lawyer/domain/chatbot/service/ChatBotService.java index a14af9eb..928e8d67 100644 --- a/backend/src/main/java/com/ai/lawyer/domain/chatbot/service/ChatBotService.java +++ b/backend/src/main/java/com/ai/lawyer/domain/chatbot/service/ChatBotService.java @@ -4,10 +4,8 @@ import com.ai.lawyer.domain.chatbot.dto.ChatDto.ChatPrecedentDto; import com.ai.lawyer.domain.chatbot.dto.ChatDto.ChatRequest; import com.ai.lawyer.domain.chatbot.dto.ChatDto.ChatResponse; -import com.ai.lawyer.domain.chatbot.dto.ExtractionDto.KeywordExtractionDto; -import com.ai.lawyer.domain.chatbot.dto.ExtractionDto.TitleExtractionDto; -import com.ai.lawyer.domain.chatbot.entity.*; -import com.ai.lawyer.domain.chatbot.repository.*; +import com.ai.lawyer.domain.chatbot.entity.History; +import com.ai.lawyer.domain.chatbot.repository.HistoryRepository; import com.ai.lawyer.domain.member.entity.Member; import com.ai.lawyer.domain.member.repositories.MemberRepository; import com.ai.lawyer.global.qdrant.service.QdrantService; @@ -17,12 +15,15 @@ import org.springframework.ai.chat.memory.ChatMemory; import org.springframework.ai.chat.memory.ChatMemoryRepository; import org.springframework.ai.chat.memory.MessageWindowChatMemory; -import org.springframework.ai.chat.messages.*; +import org.springframework.ai.chat.messages.Message; +import org.springframework.ai.chat.messages.SystemMessage; +import org.springframework.ai.chat.messages.UserMessage; import org.springframework.ai.chat.prompt.Prompt; import org.springframework.ai.chat.prompt.PromptTemplate; import org.springframework.ai.document.Document; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import reactor.core.publisher.Flux; import java.util.HashMap; @@ -36,66 +37,55 @@ public class ChatBotService { private final ChatClient chatClient; - private final QdrantService qdrantService; private final HistoryService historyService; - private final KeywordService keywordService; - private final ChatRepository chatRepository; + private final AsyncPostChatProcessingService asyncPostChatProcessingService; + + private final MemberRepository memberRepository; private final HistoryRepository historyRepository; - private final KeywordRankRepository keywordRankRepository; private final ChatMemoryRepository chatMemoryRepository; - private final MemberRepository memberRepository; - private final ChatPrecedentRepository chatPrecedentRepository; - private final ChatLawRepository chatLawRepository; @Value("${custom.ai.system-message}") private String systemMessageTemplate; - @Value("${custom.ai.title-extraction}") - private String titleExtraction; - @Value("{$custom.ai.keyword-extraction}") - private String keywordExtraction; // 핵심 로직 - // 멤버 조회 -> 벡터 검색 (판례, 법령) -> 프롬프트 생성 (시스템, 유저) -> 채팅 클라이언트 호출 (스트림) -> 응답 저장, 제목/키워드 추출 - public Flux sendMessage(Long memberId, ChatRequest chatChatRequestDto, Long roomId) { + // 멤버 조회 -> 벡터 검색 -> 프롬프트 생성 -> LLM 호출 (스트림) -> (비동기 후처리) -> 응답 반환 + @Transactional + public Flux sendMessage(Long memberId, ChatRequest chatRequestDto, Long roomId) { Member member = memberRepository.findById(memberId) - .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 회원입니다.") - ); + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 회원입니다.")); // 벡터 검색 (판례, 법령) - List similarCaseDocuments = qdrantService.searchDocument(chatChatRequestDto.getMessage(), "type", "판례"); - List similarLawDocuments = qdrantService.searchDocument(chatChatRequestDto.getMessage(), "type", "법령"); + List similarCaseDocuments = qdrantService.searchDocument(chatRequestDto.getMessage(), "type", "판례"); + List similarLawDocuments = qdrantService.searchDocument(chatRequestDto.getMessage(), "type", "법령"); - // 판례와 법령 정보를 구분 있게 포맷팅 String caseContext = formatting(similarCaseDocuments); String lawContext = formatting(similarLawDocuments); - // 채팅방 조회 or 생성 -> 없으면 생성 + // 채팅방 조회 또는 생성 History history = getOrCreateRoom(member, roomId); - // 메시지 기억 관리 (최대 10개) - // 멀티턴 -> 10개까지 기억 이거 안하면 매번 처음부터 대화 (멍충한 AI) - ChatMemory chatMemory = saveChatMemory(chatChatRequestDto, history); + // 메시지 기억 관리 (User 메시지 추가) + ChatMemory chatMemory = saveChatMemory(chatRequestDto, history); // 프롬프트 생성 Prompt prompt = getPrompt(caseContext, lawContext, chatMemory, history); - // 복잡하긴 한데 이게 제일 깔끔한듯 + // LLM 스트리밍 호출 및 클라이언트에게 즉시 응답 return chatClient.prompt(prompt) .stream() .content() .collectList() .map(fullResponseList -> String.join("", fullResponseList)) - .doOnNext(fullResponse -> handlerTasks(chatChatRequestDto, history, fullResponse, chatMemory, similarCaseDocuments, similarLawDocuments)) // 응답이 완성되면 후처리 실행 (대화 저장, 키워드/제목 추출 등) - .map(fullResponse -> ChatResponse(history, fullResponse, similarCaseDocuments, similarLawDocuments) // 최종적으로 ChatResponse DTO 생성 - ).flux() - .onErrorResume(throwable -> Flux.just(handleError(history))); // 에러 발생 시 에러 핸들링 -> 재전송 유도 + .doOnNext(fullResponse -> asyncPostChatProcessingService.processHandlerTasks(history.getHistoryId(), chatRequestDto.getMessage(), fullResponse, similarCaseDocuments, similarLawDocuments)) // 비동기 후처리 + .map(fullResponse -> createChatResponse(history, fullResponse, similarCaseDocuments, similarLawDocuments)) + .flux() + .onErrorResume(throwable -> Flux.just(handleError(history))); } - private ChatResponse ChatResponse(History history, String fullResponse, List cases, List laws) { - + private ChatResponse createChatResponse(History history, String fullResponse, List cases, List laws) { ChatPrecedentDto precedentDto = null; if (cases != null && !cases.isEmpty()) { Document firstCase = cases.get(0); @@ -110,118 +100,32 @@ private ChatResponse ChatResponse(History history, String fullResponse, List ai 답변은 비동기 후처리에서 추가 + chatMemory.add(String.valueOf(history.getHistoryId()), new UserMessage(chatRequestDto.getMessage())); return chatMemory; } private Prompt getPrompt(String caseContext, String lawContext, ChatMemory chatMemory, History history) { - Map promptContext = new HashMap<>(); promptContext.put("caseContext", caseContext); promptContext.put("lawContext", lawContext); - // 시스템 메시지와 사용자 메시지 생성 가공 PromptTemplate promptTemplate = new PromptTemplate(systemMessageTemplate); Message systemMessage = new SystemMessage(promptTemplate.create(promptContext).getContents()); UserMessage userMessage = new UserMessage(chatMemory.get(history.getHistoryId().toString()).toString()); - Prompt prompt = new Prompt(List.of(systemMessage, userMessage)); - - return prompt; - } - - private ChatResponse handleError(History history) { - return ChatResponse.builder() - .roomId(history.getHistoryId()) - .message("죄송합니다. 서비스 처리 중 오류가 발생했습니다. 요청을 다시 전송해 주세요.") - .build(); - } - - private void handlerTasks(ChatRequest chatDto, History history, String fullResponse, ChatMemory chatMemory, List similarCaseDocuments, List similarLawDocuments) { - - // 메시지 기억 저장 - chatMemory.add(String.valueOf(history.getHistoryId()), new AssistantMessage(fullResponse)); - chatMemoryRepository.saveAll(String.valueOf(history.getHistoryId()), chatMemory.get(String.valueOf(history.getHistoryId()))); - - // 채팅방 제목 설정 / 및 필터 (법과 관련 없는 질문) - setHistoryTitle(chatDto, history, fullResponse); - - // 채팅 기록 저장 - saveChatWithDocuments(history, MessageType.USER, chatDto.getMessage(), similarCaseDocuments, similarLawDocuments); - saveChatWithDocuments(history, MessageType.ASSISTANT, fullResponse, similarCaseDocuments, similarLawDocuments); - - // 키워드 추출 및 키워드 랭킹 저장 (법과 관련 없는 질문은 제외) - if (!fullResponse.contains("해당 질문은 법률")) { - extractAndUpdateKeywordRanks(chatDto.getMessage()); - } - - } - - private void extractAndUpdateKeywordRanks(String message) { - KeywordExtractionDto keywordResponse = keywordService.keywordExtract(message, keywordExtraction, KeywordExtractionDto.class); - - KeywordRank keywordRank = keywordRankRepository.findByKeyword(keywordResponse.getKeyword()); - - if (keywordRank == null) { - keywordRank = KeywordRank.builder() - .keyword(keywordResponse.getKeyword()) - .score(1L) - .build(); - } else { - keywordRank.setScore(keywordRank.getScore() + 1); - } - - keywordRankRepository.save(keywordRank); - - } - - private void setHistoryTitle(ChatRequest chatDto, History history, String fullResponse) { - String targetText = fullResponse.contains("해당 질문은 법률") ? chatDto.getMessage() : fullResponse; - TitleExtractionDto titleDto = keywordService.keywordExtract(targetText, titleExtraction, TitleExtractionDto.class); - history.setTitle(titleDto.getTitle()); - historyRepository.save(history); - } - - private void saveChatWithDocuments(History history, MessageType type, String message, List similarCaseDocuments, List similarLawDocuments) { - Chat chat = chatRepository.save(Chat.builder() - .historyId(history) - .type(type) - .message(message) - .build()); - - if (type == MessageType.USER && similarCaseDocuments != null) { - List chatPrecedents = similarCaseDocuments.stream() - .map(doc -> ChatPrecedent.builder() - .chatId(chat) - .precedentContent(doc.getText()) - .caseNumber(doc.getMetadata().get("caseNumber").toString()) - .caseName(doc.getMetadata().get("caseName").toString()) - .build()) - .toList(); - chatPrecedentRepository.saveAll(chatPrecedents); - - List chatLaws = similarLawDocuments.stream() - .map(doc -> ChatLaw.builder() - .chatId(chat) - .content(doc.getText()) - .lawName(doc.getMetadata().get("lawName").toString()) - .build()) - .toList(); - - chatLawRepository.saveAll(chatLaws); - } + return new Prompt(List.of(systemMessage, userMessage)); } private History getOrCreateRoom(Member member, Long roomId) { @@ -232,11 +136,19 @@ private History getOrCreateRoom(Member member, Long roomId) { } } - private String formatting(List similarCaseDocuments) { - String context = similarCaseDocuments.stream() + private String formatting(List documents) { + if (documents == null || documents.isEmpty()) { + return ""; + } + return documents.stream() .map(Document::getFormattedContent) .collect(Collectors.joining("\n\n---\n\n")); - return context; } + private ChatResponse handleError(History history) { + return ChatResponse.builder() + .roomId(history.getHistoryId()) + .message("죄송합니다. 서비스 처리 중 오류가 발생했습니다. 요청을 다시 전송해 주세요.") + .build(); + } } \ No newline at end of file diff --git a/backend/src/main/java/com/ai/lawyer/domain/chatbot/service/ChatService.java b/backend/src/main/java/com/ai/lawyer/domain/chatbot/service/ChatService.java deleted file mode 100644 index 22312409..00000000 --- a/backend/src/main/java/com/ai/lawyer/domain/chatbot/service/ChatService.java +++ /dev/null @@ -1,40 +0,0 @@ -package com.ai.lawyer.domain.chatbot.service; - -import com.ai.lawyer.domain.chatbot.dto.ChatDto.ChatHistoryDto; -import com.ai.lawyer.domain.chatbot.entity.Chat; -import com.ai.lawyer.domain.chatbot.repository.HistoryRepository; -import com.ai.lawyer.domain.member.entity.Member; -import com.ai.lawyer.domain.member.repositories.MemberRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.http.ResponseEntity; -import org.springframework.stereotype.Service; - -import java.util.ArrayList; -import java.util.List; - -@Service -@RequiredArgsConstructor -public class ChatService { - - private final HistoryRepository historyRepository; - private final MemberRepository memberRepository; - - public ResponseEntity> getChatHistory(Long memberId, Long roomId) { - - Member member = memberRepository.findById(memberId).orElseThrow( - () -> new IllegalArgumentException("존재하지 않는 회원입니다.") - ); - - List chats = historyRepository.findByHistoryIdAndMemberId(roomId, member).getChats(); - List chatDtos = new ArrayList<>(); - - for (Chat chat : chats) { - ChatHistoryDto dto = ChatHistoryDto.from(chat); - chatDtos.add(dto); - } - - return ResponseEntity.ok(chatDtos); - - } - -} diff --git a/backend/src/main/java/com/ai/lawyer/domain/chatbot/service/HistoryService.java b/backend/src/main/java/com/ai/lawyer/domain/chatbot/service/HistoryService.java index fb1da134..3ed27b6f 100644 --- a/backend/src/main/java/com/ai/lawyer/domain/chatbot/service/HistoryService.java +++ b/backend/src/main/java/com/ai/lawyer/domain/chatbot/service/HistoryService.java @@ -1,12 +1,15 @@ package com.ai.lawyer.domain.chatbot.service; +import com.ai.lawyer.domain.chatbot.dto.ChatDto; import com.ai.lawyer.domain.chatbot.dto.HistoryDto; +import com.ai.lawyer.domain.chatbot.entity.Chat; import com.ai.lawyer.domain.chatbot.entity.History; import com.ai.lawyer.domain.chatbot.exception.HistoryNotFoundException; import com.ai.lawyer.domain.chatbot.repository.HistoryRepository; import com.ai.lawyer.domain.member.entity.Member; import com.ai.lawyer.domain.member.repositories.MemberRepository; import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Service; import java.util.ArrayList; @@ -49,6 +52,24 @@ public String deleteHistory(Long memberId, Long roomId) { } + public ResponseEntity> getChatHistory(Long memberId, Long roomId) { + + Member member = memberRepository.findById(memberId).orElseThrow( + () -> new IllegalArgumentException("존재하지 않는 회원입니다.") + ); + + List chats = historyRepository.findByHistoryIdAndMemberId(roomId, member).getChats(); + List chatDtos = new ArrayList<>(); + + for (Chat chat : chats) { + ChatDto.ChatHistoryDto dto = ChatDto.ChatHistoryDto.from(chat); + chatDtos.add(dto); + } + + return ResponseEntity.ok(chatDtos); + + } + public History getHistory(Long roomId) { return historyRepository.findById(roomId).orElseThrow( () -> new HistoryNotFoundException(roomId)