[Developer] #424 add MCP routing chatbot demo
This commit is contained in:
@@ -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
|
||||
) {
|
||||
}
|
||||
@@ -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<McpToolView> 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<McpToolView> 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<String> 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);
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
<div class="rw-menu-group">
|
||||
<button class="rw-menu-trigger" type="button">MCP</button>
|
||||
<div class="rw-menu-panel">
|
||||
<a class="nav-link" href="/mcp-chatbot">Chatbot</a>
|
||||
<a class="nav-link" href="/mcp-reasoning">Reasoning</a>
|
||||
<a class="nav-link" href="/mcp-sse">SSE 서비스</a>
|
||||
<a class="nav-link" href="/mcp-client-demo">Client Demo</a>
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
<!doctype html>
|
||||
<html lang="ko" xmlns:th="http://www.thymeleaf.org">
|
||||
<body>
|
||||
<div th:fragment="result">
|
||||
<div class="chat-message user-message" th:if="${result}">
|
||||
<strong>질문</strong>
|
||||
<p th:text="${result.question()}">question</p>
|
||||
</div>
|
||||
|
||||
<div class="chat-message assistant-message" th:if="${errorMessage}">
|
||||
<strong>Router</strong>
|
||||
<p th:text="${errorMessage}">error</p>
|
||||
</div>
|
||||
|
||||
<div class="chat-message assistant-message" th:if="${result}">
|
||||
<div class="section-heading">
|
||||
<strong>Router</strong>
|
||||
<span class="badge"
|
||||
th:classappend="${result.status() == 'SUCCESS'} ? ' text-bg-success' : ' text-bg-secondary'"
|
||||
th:text="${result.status()}">SUCCESS</span>
|
||||
</div>
|
||||
<div class="meta-row">
|
||||
<span>Context: <code th:text="${result.contextPath()}">vpd-live</code></span>
|
||||
<span>Tool: <code th:text="${result.selectedTool()}">tool</code></span>
|
||||
</div>
|
||||
<div class="markdown-view" th:text="${result.answer()}">answer</div>
|
||||
<details class="chat-debug">
|
||||
<summary>라우팅/도구 응답 보기</summary>
|
||||
<div class="probe-exchange-grid">
|
||||
<section class="probe-exchange">
|
||||
<h3>Routing Reason</h3>
|
||||
<pre th:text="${result.routingReason()}">reason</pre>
|
||||
</section>
|
||||
<section class="probe-exchange">
|
||||
<h3>tools/list</h3>
|
||||
<pre th:text="${result.toolsListJson()}">{}</pre>
|
||||
</section>
|
||||
<section class="probe-exchange">
|
||||
<h3>tools/call</h3>
|
||||
<pre th:text="${result.toolResultJson()}">{}</pre>
|
||||
</section>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
49
src/main/resources/templates/mcp-chatbot.html
Normal file
49
src/main/resources/templates/mcp-chatbot.html
Normal file
@@ -0,0 +1,49 @@
|
||||
<!doctype html>
|
||||
<html lang="ko" xmlns:th="http://www.thymeleaf.org">
|
||||
<head th:replace="~{fragments/layout :: head('MCP Chatbot')}"></head>
|
||||
<body>
|
||||
<nav th:replace="~{fragments/layout :: nav}"></nav>
|
||||
<main class="container page-shell">
|
||||
<section class="page-heading">
|
||||
<h1>MCP Chatbot</h1>
|
||||
<p>질문을 MCP tool로 라우팅하고 ORDS/VPD 조회 결과를 답변으로 정리합니다.</p>
|
||||
</section>
|
||||
|
||||
<section class="content-band chat-shell">
|
||||
<div class="chat-message assistant-message">
|
||||
<strong>Router</strong>
|
||||
<p>보호 객체명이나 업무명을 질문에 넣으면 가장 가까운 MCP tool을 선택합니다. Bearer Token을 입력하면 실제 ORDS 호출까지 실행합니다.</p>
|
||||
</div>
|
||||
|
||||
<div id="mcp-chatbot-result" class="chat-thread">
|
||||
<div class="text-muted">대화 결과가 여기에 표시됩니다.</div>
|
||||
</div>
|
||||
|
||||
<form hx-post="/mcp-chatbot" hx-target="#mcp-chatbot-result" hx-swap="beforeend" class="chat-form">
|
||||
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
|
||||
<div class="chat-form-row">
|
||||
<label>
|
||||
Context
|
||||
<input class="form-control" name="contextPath" value="vpd-live" pattern="[A-Za-z0-9][A-Za-z0-9_-]{0,63}" required>
|
||||
</label>
|
||||
<label>
|
||||
Limit
|
||||
<input class="form-control" name="limit" type="number" min="1" max="500" value="50">
|
||||
</label>
|
||||
</div>
|
||||
<label>
|
||||
Bearer Token 원문
|
||||
<input class="form-control" name="bearerToken" type="password" autocomplete="off"
|
||||
placeholder="비우면 라우팅만 확인합니다.">
|
||||
</label>
|
||||
<label>
|
||||
질문
|
||||
<textarea class="form-control" name="question" rows="3"
|
||||
placeholder="예: BOARD_POSTS에서 이 토큰으로 보이는 행과 NULL 처리 컬럼을 요약해줘." required></textarea>
|
||||
</label>
|
||||
<button class="btn rw-btn-primary" type="submit">질문 보내기</button>
|
||||
</form>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user