diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/mcp/McpChatbotResult.java b/src/main/java/com/cloudhandson/vpdbackoffice/domain/mcp/McpChatbotResult.java new file mode 100644 index 0000000..afe9ee2 --- /dev/null +++ b/src/main/java/com/cloudhandson/vpdbackoffice/domain/mcp/McpChatbotResult.java @@ -0,0 +1,13 @@ +package com.cloudhandson.vpdbackoffice.domain.mcp; + +public record McpChatbotResult( + String contextPath, + String question, + String selectedTool, + String status, + String answer, + String routingReason, + String toolResultJson, + String toolsListJson +) { +} diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/McpChatbotService.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/McpChatbotService.java new file mode 100644 index 0000000..962b613 --- /dev/null +++ b/src/main/java/com/cloudhandson/vpdbackoffice/service/McpChatbotService.java @@ -0,0 +1,212 @@ +package com.cloudhandson.vpdbackoffice.service; + +import com.cloudhandson.vpdbackoffice.domain.mcp.McpChatbotResult; +import com.cloudhandson.vpdbackoffice.domain.mcp.McpClientDemoResult; +import com.cloudhandson.vpdbackoffice.domain.mcp.McpToolView; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.Comparator; +import java.util.List; +import java.util.Locale; +import org.springframework.stereotype.Service; + +@Service +public class McpChatbotService { + + private final McpToolRegistry toolRegistry; + private final McpClientDemoService mcpClient; + private final OpenAiCompatibleClient aiClient; + private final ObjectMapper objectMapper; + + public McpChatbotService( + McpToolRegistry toolRegistry, + McpClientDemoService mcpClient, + OpenAiCompatibleClient aiClient, + ObjectMapper objectMapper + ) { + this.toolRegistry = toolRegistry; + this.mcpClient = mcpClient; + this.aiClient = aiClient; + this.objectMapper = objectMapper; + } + + public McpChatbotResult chat( + String serverOrigin, + String contextPath, + String question, + String bearerToken, + int limit + ) { + String normalizedQuestion = normalizeQuestion(question); + List tools = toolRegistry.listTools(); + if (tools.isEmpty()) { + throw new AppException("라우팅할 MCP tool이 없습니다. ORDS 조회 Handler 대상을 먼저 등록하세요."); + } + McpToolView selectedTool = selectTool(tools, normalizedQuestion); + McpClientDemoResult clientResult = mcpClient.run( + serverOrigin, + contextPath, + selectedTool.name(), + bearerToken, + limit + ); + String routingReason = routingReason(selectedTool, normalizedQuestion); + String answer = answer(normalizedQuestion, selectedTool, clientResult, routingReason); + return new McpChatbotResult( + clientResult.contextPath(), + normalizedQuestion, + selectedTool.name(), + clientResult.status(), + answer, + routingReason, + clientResult.toolsCallResponse(), + clientResult.toolsListResponse() + ); + } + + private McpToolView selectTool(List tools, String question) { + String normalized = normalizeForMatch(question); + return tools.stream() + .max(Comparator.comparingInt(tool -> score(tool, normalized))) + .orElseThrow(); + } + + private int score(McpToolView tool, String question) { + int score = 0; + for (String token : tokens(tool.name())) { + if (question.contains(token)) { + score += 3; + } + } + for (String token : tokens(tool.displayName())) { + if (question.contains(token)) { + score += 4; + } + } + for (String token : tokens(tool.ordsPath())) { + if (question.contains(token)) { + score += 2; + } + } + return score; + } + + private String routingReason(McpToolView tool, String question) { + int score = score(tool, normalizeForMatch(question)); + if (score <= 0) { + return "질문에서 특정 보호 객체명을 찾지 못해 첫 번째 MCP tool을 선택했습니다: " + tool.displayName(); + } + return "질문과 보호 객체/tool 이름이 매칭되어 선택했습니다: " + tool.displayName() + + " (" + tool.name() + ")"; + } + + private String answer( + String question, + McpToolView tool, + McpClientDemoResult clientResult, + String routingReason + ) { + String fallback = fallbackAnswer(question, tool, clientResult, routingReason); + if (!aiClient.configured() || !"SUCCESS".equals(clientResult.status())) { + return fallback; + } + try { + return aiClient.chat(systemPrompt(), userPrompt(question, tool, clientResult, routingReason)); + } catch (Exception e) { + return fallback + "\n\nAI 답변 생성은 실패했습니다: " + e.getMessage(); + } + } + + private String fallbackAnswer( + String question, + McpToolView tool, + McpClientDemoResult clientResult, + String routingReason + ) { + if (!"SUCCESS".equals(clientResult.status())) { + return """ + 질문을 라우팅했습니다. + + 선택한 tool: %s + 라우팅 근거: %s + + Bearer Token이 없어 ORDS tools/call은 실행하지 않았습니다. 토큰을 입력하면 실제 VPD/ORDS 결과까지 조회합니다. + """.formatted(tool.name(), routingReason); + } + + JsonNode result = parse(clientResult.toolsCallResponse()); + String toolText = result.path("result").path("content").path(0).path("text").asText("{}"); + JsonNode payload = parse(toolText); + String status = payload.path("status").asText("UNKNOWN"); + int rowCount = payload.path("rowCount").asInt(0); + JsonNode maskedColumns = payload.path("maskedColumns"); + return """ + 질문을 MCP tool로 라우팅해 ORDS/VPD 결과를 조회했습니다. + + 선택한 tool: %s + 라우팅 근거: %s + ORDS 상태: %s + 반환 행 수: %d + NULL 처리 컬럼: %s + + 질문: %s + """.formatted(tool.name(), routingReason, status, rowCount, maskedColumns.toString(), question); + } + + private String systemPrompt() { + return """ + 당신은 Oracle ORDS/VPD MCP 라우팅 결과를 설명하는 운영 보조자입니다. + 제공된 MCP tool 결과 JSON만 근거로 한국어로 간결하게 답변하세요. + Bearer Token 원문은 절대 출력하지 마세요. + """; + } + + private String userPrompt(String question, McpToolView tool, McpClientDemoResult clientResult, String routingReason) { + return """ + 질문: + %s + + 선택한 tool: + - name: %s + - object: %s + - ordsPath: %s + + 라우팅 근거: + %s + + MCP tools/call 응답: + %s + + 답변 형식: + 1. 한 문장 요약 + 2. 선택한 tool과 근거 + 3. 행 필터/컬럼 NULL 처리/오류 여부 + 4. 운영자가 다음에 확인할 것 + """.formatted(question, tool.name(), tool.displayName(), tool.ordsPath(), routingReason, clientResult.toolsCallResponse()); + } + + private JsonNode parse(String value) { + try { + return objectMapper.readTree(value); + } catch (Exception e) { + return objectMapper.createObjectNode().put("raw", value); + } + } + + private String normalizeQuestion(String question) { + if (question == null || question.isBlank()) { + return "현재 토큰으로 조회 가능한 데이터를 요약해줘."; + } + return question.trim(); + } + + private List tokens(String value) { + return List.of(normalizeForMatch(value).split("[^a-z0-9가-힣]+")).stream() + .filter(token -> token.length() >= 2) + .toList(); + } + + private String normalizeForMatch(String value) { + return value == null ? "" : value.toLowerCase(Locale.ROOT); + } +} diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/web/McpChatbotController.java b/src/main/java/com/cloudhandson/vpdbackoffice/web/McpChatbotController.java new file mode 100644 index 0000000..75f8475 --- /dev/null +++ b/src/main/java/com/cloudhandson/vpdbackoffice/web/McpChatbotController.java @@ -0,0 +1,47 @@ +package com.cloudhandson.vpdbackoffice.web; + +import com.cloudhandson.vpdbackoffice.service.McpChatbotService; +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.servlet.support.ServletUriComponentsBuilder; + +@Controller +public class McpChatbotController { + + private final McpChatbotService chatbotService; + + public McpChatbotController(McpChatbotService chatbotService) { + this.chatbotService = chatbotService; + } + + @GetMapping("/mcp-chatbot") + public String page() { + return "mcp-chatbot"; + } + + @PostMapping("/mcp-chatbot") + public String chat( + @RequestParam(defaultValue = "vpd-live") String contextPath, + @RequestParam(defaultValue = "") String question, + @RequestParam(defaultValue = "") String bearerToken, + @RequestParam(defaultValue = "50") int limit, + HttpServletRequest request, + Model model + ) { + try { + String serverOrigin = ServletUriComponentsBuilder.fromRequestUri(request) + .replacePath(null) + .replaceQuery(null) + .build() + .toUriString(); + model.addAttribute("result", chatbotService.chat(serverOrigin, contextPath, question, bearerToken, limit)); + } catch (Exception e) { + model.addAttribute("errorMessage", e.getMessage()); + } + return "fragments/mcp-chatbot-result :: result"; + } +} diff --git a/src/main/resources/static/css/app.css b/src/main/resources/static/css/app.css index 4388c2a..7bd880e 100644 --- a/src/main/resources/static/css/app.css +++ b/src/main/resources/static/css/app.css @@ -503,6 +503,85 @@ body { line-height: 1.45; } +.chat-shell { + display: grid; + gap: 1rem; +} + +.chat-thread { + display: grid; + gap: .75rem; +} + +.chat-message { + border: 1px solid var(--rw-border); + border-radius: 8px; + max-width: 100%; + padding: .85rem; +} + +.chat-message strong { + display: block; + font-size: .82rem; + margin-bottom: .45rem; + text-transform: uppercase; +} + +.chat-message p { + margin: 0; +} + +.user-message { + background: #edf2f7; + margin-left: auto; + width: min(760px, 92%); +} + +.assistant-message { + background: var(--rw-surface-muted); + width: min(920px, 100%); +} + +.chat-form { + border-top: 1px solid var(--rw-border); + display: grid; + gap: .75rem; + padding-top: 1rem; +} + +.chat-form-row { + display: grid; + gap: .75rem; + grid-template-columns: minmax(180px, 1fr) minmax(120px, 180px); +} + +.chat-form label { + color: var(--rw-muted); + font-size: .875rem; + font-weight: 700; +} + +.chat-debug { + margin-top: .75rem; +} + +.chat-debug summary { + color: var(--rw-primary); + cursor: pointer; + font-weight: 700; +} + +@media (max-width: 720px) { + .chat-form-row { + grid-template-columns: 1fr; + } + + .user-message, + .assistant-message { + width: 100%; + } +} + .probe-exchange { border: 1px solid var(--rw-border); border-radius: 8px; diff --git a/src/main/resources/templates/fragments/layout.html b/src/main/resources/templates/fragments/layout.html index ad2c139..bcc4085 100644 --- a/src/main/resources/templates/fragments/layout.html +++ b/src/main/resources/templates/fragments/layout.html @@ -35,6 +35,7 @@
+ Chatbot Reasoning SSE 서비스 Client Demo diff --git a/src/main/resources/templates/fragments/mcp-chatbot-result.html b/src/main/resources/templates/fragments/mcp-chatbot-result.html new file mode 100644 index 0000000..7216ac8 --- /dev/null +++ b/src/main/resources/templates/fragments/mcp-chatbot-result.html @@ -0,0 +1,47 @@ + + + +
+
+ 질문 +

question

+
+ +
+ Router +

error

+
+ +
+
+ Router + SUCCESS +
+
+ Context: vpd-live + Tool: tool +
+
answer
+
+ 라우팅/도구 응답 보기 +
+
+

Routing Reason

+
reason
+
+
+

tools/list

+
{}
+
+
+

tools/call

+
{}
+
+
+
+
+
+ + diff --git a/src/main/resources/templates/mcp-chatbot.html b/src/main/resources/templates/mcp-chatbot.html new file mode 100644 index 0000000..ba19f4b --- /dev/null +++ b/src/main/resources/templates/mcp-chatbot.html @@ -0,0 +1,49 @@ + + + + + +
+
+

MCP Chatbot

+

질문을 MCP tool로 라우팅하고 ORDS/VPD 조회 결과를 답변으로 정리합니다.

+
+ +
+
+ Router +

보호 객체명이나 업무명을 질문에 넣으면 가장 가까운 MCP tool을 선택합니다. Bearer Token을 입력하면 실제 ORDS 호출까지 실행합니다.

+
+ +
+
대화 결과가 여기에 표시됩니다.
+
+ +
+ +
+ + +
+ + + +
+
+
+ +