-
Notifications
You must be signed in to change notification settings - Fork 9
[고객지원 챗봇 만들기] 김준영 제출합니다. #2
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
2735626
f75ad57
f6fa3c4
d250287
4ecdb13
4ac5a9a
46b20a7
62b7aad
28b3413
8759009
8ec9bac
d2901a6
64ee947
aea6e10
e3fe990
071ec9b
6eb21ee
838f408
162cb3c
ebaab45
ef3815c
f8f89e7
deb627d
2c6e041
9383c9a
3700df4
53caf47
71121c4
5106f1d
ea159ca
918edd4
d35b780
0d96e85
9f1ec40
f287a6d
24f09c9
5f0b94c
c5bb941
165f724
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,16 @@ | ||
| package com.cholog.bootcamp.config; | ||
|
|
||
| import org.springframework.ai.embedding.EmbeddingModel; | ||
| import org.springframework.ai.vectorstore.SimpleVectorStore; | ||
| import org.springframework.ai.vectorstore.VectorStore; | ||
| import org.springframework.context.annotation.Bean; | ||
| import org.springframework.context.annotation.Configuration; | ||
|
|
||
| @Configuration | ||
| public class VectorStoreConfig { | ||
|
|
||
| @Bean | ||
| public VectorStore vectorStore(EmbeddingModel embeddingModel) { | ||
| return SimpleVectorStore.builder(embeddingModel).build(); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,35 @@ | ||
| package com.cholog.bootcamp.controller; | ||
|
|
||
| import java.util.List; | ||
|
|
||
| import org.springframework.stereotype.Controller; | ||
| import org.springframework.ui.Model; | ||
| import org.springframework.web.bind.annotation.GetMapping; | ||
|
|
||
| @Controller | ||
| public class ChatPageController { | ||
|
|
||
| @GetMapping({"/", "/chat"}) | ||
| public String chat(Model model) { | ||
| model.addAttribute("pageTitle", "초록 고객지원 챗봇"); | ||
| model.addAttribute("initialMessage", """ | ||
| 안녕하세요. 초록 고객지원 챗봇입니다.🤖 | ||
| 무엇을 도와드릴까요? | ||
|
|
||
| 📞 운영시간 안내 | ||
| 자동 챗봇은 24시간 이용하실 수 있습니다. | ||
| 상담사 연결 및 전화 상담은 평일 오전 9시부터 오후 6시까지 가능하며, 주말 및 공휴일에는 운영되지 않습니다. | ||
| 운영 시간 외 문의는 챗봇을 이용하시거나 이메일로 남겨주시면 다음 영업일부터 순차적으로 확인해 드리겠습니다. | ||
|
|
||
| ☎️ 문의 전화: 1588-0000 | ||
| 📧 이메일: support@cholog.kr | ||
| 💬 카카오톡: @초록 | ||
| """); | ||
| model.addAttribute("quickPrompts", List.of( | ||
| "배송은 보통 얼마나 걸리나요?", | ||
| "반품 신청 기준을 알려주세요.", | ||
| "멤버십 등급 혜택이 궁금해요." | ||
| )); | ||
| return "chat"; | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,31 @@ | ||
| package com.cholog.bootcamp.controller; | ||
|
|
||
| import org.springframework.http.ResponseEntity; | ||
| import org.springframework.web.bind.annotation.PostMapping; | ||
| import org.springframework.web.bind.annotation.RequestBody; | ||
| import org.springframework.web.bind.annotation.RequestMapping; | ||
| import org.springframework.web.bind.annotation.RestController; | ||
|
|
||
| import com.cholog.bootcamp.dto.ChatbotRequest; | ||
| import com.cholog.bootcamp.dto.ChatbotResponse; | ||
| import com.cholog.bootcamp.service.ChatbotService; | ||
|
|
||
| import lombok.RequiredArgsConstructor; | ||
| import lombok.extern.slf4j.Slf4j; | ||
|
|
||
| @Slf4j | ||
| @RequiredArgsConstructor | ||
| @RequestMapping("/api/chat") | ||
| @RestController | ||
| public class ChatbotController { | ||
|
|
||
| private final ChatbotService chatbotService; | ||
|
|
||
| @PostMapping | ||
| public ResponseEntity<ChatbotResponse> chat( | ||
| @RequestBody ChatbotRequest request | ||
| ) { | ||
| ChatbotResponse response = chatbotService.chat(request); | ||
| return ResponseEntity.ok().body(response); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| package com.cholog.bootcamp.dto; | ||
|
|
||
| public record ChatbotRequest( | ||
| String question | ||
| ) { | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,27 @@ | ||
| package com.cholog.bootcamp.dto; | ||
|
|
||
| import org.springframework.ai.chat.metadata.Usage; | ||
|
|
||
| public record ChatbotResponse( | ||
| String answer, | ||
| TokenUsageInfo tokenUsage | ||
| ) { | ||
|
|
||
| public static ChatbotResponse from(String answer, Usage usage) { | ||
| return new ChatbotResponse( | ||
| answer, | ||
| new TokenUsageInfo( | ||
| usage.getPromptTokens(), | ||
| usage.getCompletionTokens(), | ||
| usage.getTotalTokens() | ||
| ) | ||
| ); | ||
| } | ||
|
|
||
| private record TokenUsageInfo( | ||
| int promptTokens, | ||
| int completionTokens, | ||
| int totalTokens | ||
| ) { | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,106 @@ | ||
| package com.cholog.bootcamp.service; | ||
|
|
||
| import java.io.IOException; | ||
| import java.util.List; | ||
| import java.util.stream.Collectors; | ||
|
|
||
| import org.springframework.ai.chat.client.ChatClient; | ||
| import org.springframework.ai.chat.metadata.Usage; | ||
| import org.springframework.ai.chat.model.ChatResponse; | ||
| import org.springframework.ai.document.Document; | ||
| import org.springframework.ai.reader.TextReader; | ||
| import org.springframework.ai.vectorstore.SearchRequest; | ||
| import org.springframework.ai.vectorstore.VectorStore; | ||
| import org.springframework.core.io.support.PathMatchingResourcePatternResolver; | ||
| import org.springframework.core.io.support.ResourcePatternResolver; | ||
| import org.springframework.stereotype.Service; | ||
|
|
||
| import com.cholog.bootcamp.dto.ChatbotRequest; | ||
| import com.cholog.bootcamp.dto.ChatbotResponse; | ||
|
|
||
| import lombok.extern.slf4j.Slf4j; | ||
|
|
||
| @Slf4j | ||
| @Service | ||
| public class ChatbotService { | ||
|
|
||
| private final ChatClient chatClient; | ||
| private final VectorStore vectorStore; | ||
| private final ResourcePatternResolver resolver; | ||
|
|
||
| public ChatbotService( | ||
| VectorStore vectorStore, | ||
| MarkdownReader markdownReader, | ||
| ChatClient.Builder chatClientBuilder | ||
| ) { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. public ChatbotService(...) {
...
vectorStore.add(markdownReader.loadAll());
...
}생성자에서 임베딩 호출 부수효과가 있어요. 트레이드오프:
분리 방향)
"생성과 초기화를 같은 자리에 두지 마라" 라는 원칙, 한 번 고민해보면 좋겠습니다~ |
||
| this.chatClient = chatClientBuilder.build(); | ||
| this.vectorStore = vectorStore; | ||
| vectorStore.add(markdownReader.loadAll()); | ||
| this.resolver = new PathMatchingResourcePatternResolver(); | ||
| } | ||
|
|
||
| public ChatbotResponse chat(ChatbotRequest request) { | ||
| // 검색 | ||
| SearchRequest searchRequest = getSearchRequest(request.question(), 4); | ||
| List<Document> documents = vectorStore.similaritySearch(searchRequest); | ||
|
|
||
| // 증강 & 생성 | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. String answer = chatResponse.getResult().getOutput().getText();
Usage usage = chatResponse.getMetadata().getUsage();아래 케이스에서 NPE → 사용자에게 500이 갈 가능성이 있어요.
방어적 코드 추가도 좋고, 더 나아가 "LLM 호출 자체가 실패했을 때 어떤 응답을 사용자에게 보낼지" 를 명시적으로 분기하는 것도 한 번 고민해보면 좋겠습니다. "일시적 장애로 답변이 어렵습니다" 같은 명시적 응답이 운영자 alert 잡는 데도 도움이 될 수 있어요! |
||
| documents = getFullDocuments(documents); | ||
| String context = getContext(documents); | ||
| ChatResponse chatResponse = chatClient.prompt() | ||
| .system(""" | ||
| 당신은 초록 고객센터의 챗봇입니다. | ||
| 주어진 [컨텍스트]를 기반으로 [사용자 질문]에 답변해주세요. | ||
|
|
||
| 답변 규칙 | ||
| - 제공된 컨텍스트를 기반으로만 답변하세요. 절대 일반 상식으로 추론하지 마세요. | ||
| - 만약 주어진 컨텍스트로 답변할 수 없다면 모르겠다고 안내하세요. | ||
| - 내용이 충돌하는 경우 다음 우선순위를 따라 답변 합니다. | ||
| - 질문 도메인과 가장 근접한 내용 | ||
| - 더 구체적인 상황을 다루는 내용 | ||
| - 더 최신 버전의 내용 | ||
| """) | ||
| .user(""" | ||
| [사용자 질문] | ||
| %s | ||
|
|
||
| [컨텍스트] | ||
| %s | ||
| """.formatted(request.question(), context)) | ||
| .call() | ||
| .chatResponse(); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. system prompt 너무 잘 짜셨네요 👍 특히 아래 부분이 인상적이에요. 이 규칙은 코드 가 아니라 프롬프트 에 표현한 점이 좋습니다 — 정책 결정을 LLM과 공유하는 자연스러운 자리예요. 다만 위치가 인라인 String이라:
외부 @Value("classpath:prompts/faq-system.st")
private Resource systemPrompt; |
||
|
|
||
| String answer = chatResponse.getResult().getOutput().getText(); | ||
| Usage usage = chatResponse.getMetadata().getUsage(); | ||
| return ChatbotResponse.from(answer, usage); | ||
| } | ||
|
|
||
| private List<Document> getFullDocuments(List<Document> documents) { | ||
| return documents.stream() | ||
| .map(document -> document.getMetadata().get("filename").toString()) | ||
| .distinct() | ||
| .map(filename -> { | ||
| try { | ||
| return resolver.getResources("classpath:data/**/" + filename)[0]; | ||
| } catch (IOException e) { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 이 부분이 핵심인 것 같아서 자세히 남길게요. return documents.stream()
.map(document -> document.getMetadata().get("filename").toString())
.distinct()
.map(filename -> {
try {
return resolver.getResources("classpath:data/**/" + filename)[0];
} catch (IOException e) {
throw new RuntimeException(e);
}
})
.map(TextReader::new)
.flatMap(reader -> reader.get().stream())
.toList();흐름:
즉 검색은 "어떤 파일이 관련 있는가" 까지만 알려주고, LLM 입력은 4개 파일 전체가 됩니다. 문제:
에 대해 고민해보면 좋겠습니다! |
||
| throw new RuntimeException(e); | ||
| } | ||
| }) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 정보 없는 throw 안티패턴이 보이네요. throw new RuntimeException(e);운영 시점에 이 코드가 발화하면 아래와 같은 문제가 있습니다.
최소한 아래와 같이 정보를 담아주면 좋겠습니다. throw new IllegalStateException("문서 로드 실패: " + filename, e);에러 메시지도 의미 있는 정보 여야 한다는 원칙, 한 번 참고해주세요~ |
||
| .map(TextReader::new) | ||
| .flatMap(reader -> reader.get().stream()) | ||
| .toList(); | ||
| } | ||
|
|
||
| private static String getContext(List<Document> documents) { | ||
| return documents.stream() | ||
| .map(Document::getText) | ||
| .collect(Collectors.joining("\n\n")); | ||
| } | ||
|
|
||
| private SearchRequest getSearchRequest(String query, int k) { | ||
| return SearchRequest.builder() | ||
| .query(query) | ||
| .topK(k) | ||
| .build(); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,48 @@ | ||
| package com.cholog.bootcamp.service; | ||
|
|
||
| import java.util.ArrayList; | ||
| import java.util.List; | ||
|
|
||
| import org.springframework.ai.document.Document; | ||
| import org.springframework.ai.reader.markdown.MarkdownDocumentReader; | ||
| import org.springframework.ai.reader.markdown.config.MarkdownDocumentReaderConfig; | ||
| import org.springframework.beans.factory.annotation.Value; | ||
| import org.springframework.core.io.Resource; | ||
| import org.springframework.stereotype.Component; | ||
|
|
||
| import lombok.extern.slf4j.Slf4j; | ||
|
|
||
| @Slf4j | ||
| @Component | ||
| public class MarkdownReader { | ||
|
|
||
| private final Resource[] resources; | ||
|
|
||
| public MarkdownReader(@Value("classpath:data/**/*.md") Resource[] resources) { | ||
| this.resources = resources; | ||
| } | ||
|
|
||
| public List<Document> loadAll() { | ||
| List<Document> allDocuments = new ArrayList<>(); | ||
| for (Resource resource : resources) { | ||
| MarkdownDocumentReaderConfig config = MarkdownDocumentReaderConfig.builder() | ||
| .withHorizontalRuleCreateDocument(true) | ||
| .withIncludeCodeBlock(false) | ||
| .withIncludeBlockquote(false) | ||
| .withAdditionalMetadata("filename", resource.getFilename()) | ||
| .build(); | ||
|
|
||
| MarkdownDocumentReader reader = new MarkdownDocumentReader(resource, config); | ||
| allDocuments.addAll(reader.get()); | ||
| } | ||
|
|
||
| allDocuments.forEach(doc -> log.info( | ||
| "filename={}, title={}, text={}", | ||
| doc.getMetadata().get("filename"), | ||
| doc.getMetadata().get("title"), | ||
| doc.getText() | ||
| )); | ||
|
|
||
| return allDocuments; | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
웹 UI를 직접 구현하신 부분 너무 좋네요 👍
다만
initialMessage/quickPrompts가 컨트롤러에 인라인 String이라, 아래와 같은 확장 시 복붙이 발생할 수 있어요.챗봇 본체를 채널-무관 인터페이스로 두고, 채널별 어댑터가 각자의 환영 메시지를 가져가는 형태로 한 번 그려보면 어떨까요? 예시)