[Developer] #424 add MCP reasoning tab
This commit is contained in:
@@ -0,0 +1,162 @@
|
||||
package com.cloudhandson.vpdbackoffice.service;
|
||||
|
||||
import com.cloudhandson.vpdbackoffice.domain.mcp.McpReasoningCommand;
|
||||
import com.cloudhandson.vpdbackoffice.domain.mcp.McpReasoningResult;
|
||||
import com.cloudhandson.vpdbackoffice.domain.mcp.McpToolView;
|
||||
import com.cloudhandson.vpdbackoffice.domain.probe.ProbeCommand;
|
||||
import com.cloudhandson.vpdbackoffice.domain.probe.ProbeResult;
|
||||
import com.cloudhandson.vpdbackoffice.domain.probe.ProbeStatus;
|
||||
import com.cloudhandson.vpdbackoffice.domain.protectedobject.ProtectedObject;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
import java.util.List;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@Service
|
||||
public class McpReasoningService {
|
||||
|
||||
private static final int MAX_EVIDENCE_ROWS = 20;
|
||||
|
||||
private final ProtectedObjectService protectedObjectService;
|
||||
private final OrdsProbeService ordsProbeService;
|
||||
private final McpToolRegistry toolRegistry;
|
||||
private final OpenAiCompatibleClient aiClient;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public McpReasoningService(
|
||||
ProtectedObjectService protectedObjectService,
|
||||
OrdsProbeService ordsProbeService,
|
||||
McpToolRegistry toolRegistry,
|
||||
OpenAiCompatibleClient aiClient,
|
||||
ObjectMapper objectMapper
|
||||
) {
|
||||
this.protectedObjectService = protectedObjectService;
|
||||
this.ordsProbeService = ordsProbeService;
|
||||
this.toolRegistry = toolRegistry;
|
||||
this.aiClient = aiClient;
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
public McpReasoningResult reason(McpReasoningCommand command) {
|
||||
ProtectedObject object = protectedObjectService.assertEnabled(command.objectId());
|
||||
McpToolView tool = toolRegistry.toolFor(object);
|
||||
ProbeResult probeResult = ordsProbeService.runProbe(new ProbeCommand(
|
||||
command.objectId(),
|
||||
command.bearerToken(),
|
||||
normalizeLimit(command.limit())
|
||||
));
|
||||
String evidenceJson = evidenceJson(tool, probeResult);
|
||||
String prompt = buildPrompt(command.question(), tool, evidenceJson);
|
||||
|
||||
if (!aiClient.configured()) {
|
||||
return new McpReasoningResult(
|
||||
"AI_NOT_CONFIGURED",
|
||||
tool.name(),
|
||||
fallbackAnswer(command.question(), probeResult),
|
||||
prompt,
|
||||
evidenceJson,
|
||||
probeResult
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
String answer = aiClient.chat(systemPrompt(), prompt);
|
||||
return new McpReasoningResult("SUCCESS", tool.name(), answer, prompt, evidenceJson, probeResult);
|
||||
} catch (Exception e) {
|
||||
return new McpReasoningResult(
|
||||
"AI_CALL_FAILED",
|
||||
tool.name(),
|
||||
"AI 호출은 실패했지만 ORDS 도구 실행 증거는 수집했습니다. 상세: " + e.getMessage(),
|
||||
prompt,
|
||||
evidenceJson,
|
||||
probeResult
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private int normalizeLimit(int limit) {
|
||||
if (limit < 1) {
|
||||
return 50;
|
||||
}
|
||||
return Math.min(limit, 500);
|
||||
}
|
||||
|
||||
private String evidenceJson(McpToolView tool, ProbeResult result) {
|
||||
try {
|
||||
ObjectNode evidence = objectMapper.createObjectNode();
|
||||
evidence.put("toolName", tool.name());
|
||||
evidence.put("objectId", tool.objectId());
|
||||
evidence.put("displayName", tool.displayName());
|
||||
evidence.put("ordsPath", tool.ordsPath());
|
||||
evidence.put("status", result.status().name());
|
||||
evidence.put("rowCount", result.rowCount());
|
||||
evidence.set("columns", objectMapper.valueToTree(result.columns()));
|
||||
evidence.set("maskedColumns", objectMapper.valueToTree(result.maskedColumns()));
|
||||
evidence.set("rows", objectMapper.valueToTree(limitedRows(result)));
|
||||
evidence.put("errorCode", result.errorCode());
|
||||
evidence.put("errorMessage", result.errorMessage());
|
||||
evidence.put("requestHeaders", result.requestHeaders());
|
||||
evidence.put("requestPayload", result.requestPayload());
|
||||
evidence.put("responseHeaders", result.responseHeaders());
|
||||
evidence.put("responseBody", result.responseBody());
|
||||
return objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(evidence);
|
||||
} catch (Exception e) {
|
||||
throw new AppException("MCP evidence JSON 생성 실패: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private List<?> limitedRows(ProbeResult result) {
|
||||
if (result.rows().size() <= MAX_EVIDENCE_ROWS) {
|
||||
return result.rows();
|
||||
}
|
||||
return result.rows().subList(0, MAX_EVIDENCE_ROWS);
|
||||
}
|
||||
|
||||
private String buildPrompt(String question, McpToolView tool, String evidenceJson) {
|
||||
String normalizedQuestion = question == null || question.isBlank()
|
||||
? "이 ORDS/VPD 검증 결과를 권한 관점에서 요약해줘."
|
||||
: question.trim();
|
||||
return """
|
||||
질문:
|
||||
%s
|
||||
|
||||
사용한 도구:
|
||||
- name: %s
|
||||
- object: %s
|
||||
- ordsPath: %s
|
||||
|
||||
도구 실행 증거(JSON):
|
||||
%s
|
||||
|
||||
답변 요구사항:
|
||||
- 한국어로 답변한다.
|
||||
- 증거 JSON에 없는 데이터는 추측하지 않는다.
|
||||
- VPD 행 필터, 컬럼 NULL 처리, ORDS 오류 여부를 구분한다.
|
||||
- 운영자가 다음에 확인할 액션이 있으면 짧게 제시한다.
|
||||
""".formatted(normalizedQuestion, tool.name(), tool.displayName(), tool.ordsPath(), evidenceJson);
|
||||
}
|
||||
|
||||
private String systemPrompt() {
|
||||
return """
|
||||
당신은 Oracle ADB VPD/Redaction/ORDS 권한 검증 보조자입니다.
|
||||
백오피스가 제공한 도구 실행 증거만 근거로 판단하고, 토큰 원문이나 비밀 값을 재출력하지 마세요.
|
||||
""";
|
||||
}
|
||||
|
||||
private String fallbackAnswer(String question, ProbeResult result) {
|
||||
if (result.status() == ProbeStatus.SUCCESS) {
|
||||
return "AI base URL/API Key 설정이 없어 모델 호출은 건너뛰었습니다. ORDS 호출은 성공했고 "
|
||||
+ result.rowCount() + "건이 반환되었습니다. 질문: " + safeQuestion(question);
|
||||
}
|
||||
return "AI base URL/API Key 설정이 없어 모델 호출은 건너뛰었습니다. ORDS 도구 실행 상태는 "
|
||||
+ result.status().name() + "입니다. 오류: "
|
||||
+ (result.errorMessage() == null ? "없음" : result.errorMessage());
|
||||
}
|
||||
|
||||
private String safeQuestion(String question) {
|
||||
if (question == null || question.isBlank()) {
|
||||
return "기본 요약";
|
||||
}
|
||||
return question.trim();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package com.cloudhandson.vpdbackoffice.service;
|
||||
|
||||
import com.cloudhandson.vpdbackoffice.domain.mcp.McpToolView;
|
||||
import com.cloudhandson.vpdbackoffice.domain.protectedobject.ProtectedObject;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@Service
|
||||
public class McpToolRegistry {
|
||||
|
||||
private final ProtectedObjectService protectedObjectService;
|
||||
|
||||
public McpToolRegistry(ProtectedObjectService protectedObjectService) {
|
||||
this.protectedObjectService = protectedObjectService;
|
||||
}
|
||||
|
||||
public List<McpToolView> listTools() {
|
||||
return protectedObjectService.findEnabled().stream()
|
||||
.map(this::toTool)
|
||||
.toList();
|
||||
}
|
||||
|
||||
public McpToolView toolFor(ProtectedObject object) {
|
||||
return toTool(object);
|
||||
}
|
||||
|
||||
private McpToolView toTool(ProtectedObject object) {
|
||||
String name = "ords.query." + safeName(object.owner()) + "." + safeName(object.objectName());
|
||||
return new McpToolView(
|
||||
name,
|
||||
object.displayName() + " 보호 객체를 Bearer Token으로 ORDS 호출해 VPD/Redaction 적용 결과를 조회합니다.",
|
||||
object.objectId(),
|
||||
object.displayName(),
|
||||
object.ordsPath()
|
||||
);
|
||||
}
|
||||
|
||||
private String safeName(String value) {
|
||||
return value.toLowerCase(Locale.ROOT).replaceAll("[^a-z0-9_]+", "_");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package com.cloudhandson.vpdbackoffice.service;
|
||||
|
||||
import com.cloudhandson.vpdbackoffice.config.BackofficeProperties;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.node.ArrayNode;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
import java.net.URI;
|
||||
import java.time.Duration;
|
||||
import org.springframework.boot.web.client.RestTemplateBuilder;
|
||||
import org.springframework.http.HttpEntity;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
|
||||
@Service
|
||||
public class OpenAiCompatibleClient {
|
||||
|
||||
private final BackofficeProperties properties;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final RestTemplateBuilder restTemplateBuilder;
|
||||
|
||||
public OpenAiCompatibleClient(
|
||||
BackofficeProperties properties,
|
||||
ObjectMapper objectMapper,
|
||||
RestTemplateBuilder restTemplateBuilder
|
||||
) {
|
||||
this.properties = properties;
|
||||
this.objectMapper = objectMapper;
|
||||
this.restTemplateBuilder = restTemplateBuilder;
|
||||
}
|
||||
|
||||
public boolean configured() {
|
||||
BackofficeProperties.Ai ai = properties.ai();
|
||||
return ai != null
|
||||
&& ai.enabled()
|
||||
&& hasText(ai.baseUrl())
|
||||
&& hasText(ai.model())
|
||||
&& hasText(ai.apiKey());
|
||||
}
|
||||
|
||||
public String chat(String systemPrompt, String userPrompt) {
|
||||
if (!configured()) {
|
||||
throw new AppException("AI 호출 설정이 없습니다.");
|
||||
}
|
||||
|
||||
BackofficeProperties.Ai ai = properties.ai();
|
||||
ObjectNode request = objectMapper.createObjectNode();
|
||||
request.put("model", ai.model());
|
||||
request.put("temperature", 0.1);
|
||||
request.put("max_tokens", 1200);
|
||||
ArrayNode messages = request.putArray("messages");
|
||||
messages.add(message("system", systemPrompt));
|
||||
messages.add(message("user", userPrompt));
|
||||
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.setContentType(MediaType.APPLICATION_JSON);
|
||||
headers.setBearerAuth(ai.apiKey());
|
||||
|
||||
Duration timeout = ai.timeout() == null ? Duration.ofSeconds(30) : ai.timeout();
|
||||
RestTemplate restTemplate = restTemplateBuilder
|
||||
.setConnectTimeout(timeout)
|
||||
.setReadTimeout(timeout)
|
||||
.build();
|
||||
ResponseEntity<String> response = restTemplate.postForEntity(
|
||||
endpoint(ai.baseUrl()),
|
||||
new HttpEntity<>(request.toString(), headers),
|
||||
String.class
|
||||
);
|
||||
return extractAnswer(response.getBody());
|
||||
}
|
||||
|
||||
private ObjectNode message(String role, String content) {
|
||||
ObjectNode message = objectMapper.createObjectNode();
|
||||
message.put("role", role);
|
||||
message.put("content", content);
|
||||
return message;
|
||||
}
|
||||
|
||||
private URI endpoint(String baseUrl) {
|
||||
String trimmed = baseUrl.trim();
|
||||
if (trimmed.endsWith("/chat/completions")) {
|
||||
return URI.create(trimmed);
|
||||
}
|
||||
String withoutSlash = trimmed.endsWith("/") ? trimmed.substring(0, trimmed.length() - 1) : trimmed;
|
||||
if (withoutSlash.endsWith("/v1")) {
|
||||
return URI.create(withoutSlash + "/chat/completions");
|
||||
}
|
||||
return URI.create(withoutSlash + "/v1/chat/completions");
|
||||
}
|
||||
|
||||
private String extractAnswer(String body) {
|
||||
try {
|
||||
JsonNode root = objectMapper.readTree(body);
|
||||
JsonNode content = root.path("choices").path(0).path("message").path("content");
|
||||
if (content.isTextual() && !content.asText().isBlank()) {
|
||||
return content.asText();
|
||||
}
|
||||
JsonNode outputText = root.path("output_text");
|
||||
if (outputText.isTextual() && !outputText.asText().isBlank()) {
|
||||
return outputText.asText();
|
||||
}
|
||||
return objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(root);
|
||||
} catch (Exception e) {
|
||||
throw new AppException("AI 응답을 해석할 수 없습니다: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private boolean hasText(String value) {
|
||||
return value != null && !value.isBlank();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user