[Developer] #424 add MCP reasoning tab
This commit is contained in:
11
.env.example
11
.env.example
@@ -24,6 +24,17 @@ export ADB_PASSWORD="" # 비워두면
|
||||
export BACKOFFICE_ORDS_BASE_URL="https://yh0olybn5pqce4n-d8aukro81636mon0.adb.ap-seoul-1.oraclecloudapps.com/ords"
|
||||
export BACKOFFICE_ORDS_TIMEOUT_SECONDS="10"
|
||||
|
||||
# --- (2c) OpenAI 호환 AI 호출 (MCP-style Reasoning 탭) ---
|
||||
export BACKOFFICE_AI_ENABLED="false"
|
||||
export BACKOFFICE_AI_BASE_URL="" # 예: https://inference.generativeai.<region>.oci.oraclecloud.com/20231130/actions/chat
|
||||
export BACKOFFICE_AI_MODEL="" # 예: vpdtest1 또는 서비스가 요구하는 모델명
|
||||
export BACKOFFICE_AI_API_KEY=""
|
||||
export BACKOFFICE_AI_TIMEOUT_SECONDS="30"
|
||||
|
||||
# 로컬 키 별칭은 .env에만 저장하세요. .env.example에는 원문 키를 넣지 않습니다.
|
||||
export VPDTEST1_API_KEY=""
|
||||
export VPDTEST2_API_KEY=""
|
||||
|
||||
# --- (3) 데모용 ADB 엔드유저 비밀번호 (sql/adb/07_end_users.sql 에서 사용) ---
|
||||
# ADB 비번 정책: 12자 이상, 대/소/숫자/특수 조합.
|
||||
# 4명의 데모 유저:
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
- ADB 권한 관리 테이블과 ORDS Bearer Key 검증 테이블을 조회/수정하는 서비스
|
||||
- 보호 객체, 사용자, 역할, 행 규칙, 컬럼 표시 권한, Bearer Token 관리 화면
|
||||
- Bearer Token을 사용해 ORDS에 `SELECT *` 성격의 조회를 실행하고 VPD/Redaction 적용 결과를 확인하는 화면
|
||||
- ORDS 검증 결과를 내부 도구 실행 결과로 감싸 OpenAI 호환 LLM에 전달하는 MCP-style reasoning 화면
|
||||
- Thymeleaf + HTMX + Alpine.js + Bootstrap 5 기반 서버 렌더링 프론트
|
||||
- Audit log 저장과 오류 유형 구분 표시
|
||||
- **제외 (out of scope)**:
|
||||
@@ -43,7 +44,7 @@
|
||||
- ORDS Handler 또는 REST Enabled SQL 성격의 조회 API
|
||||
- Oracle JDBC, MyBatis, Spring Security, Thymeleaf, HTMX
|
||||
- 제약:
|
||||
- `.env`와 DB 비밀번호, Bearer Token 원문은 git에 저장하지 않는다.
|
||||
- `.env`와 DB 비밀번호, Bearer Token 원문, AI API Key는 git에 저장하지 않는다.
|
||||
- 객체명은 반드시 DB에 등록된 보호 객체 whitelist로 제한한다.
|
||||
- 백오피스 DB 계정은 권한 관리 테이블과 검증용 ORDS 호출에 필요한 최소 권한만 갖는다.
|
||||
- VPD는 행(row) 통제, Redaction은 컬럼 값 마스킹/NULL 처리로 설명하고 구현한다.
|
||||
@@ -114,6 +115,14 @@ src/test/java/com/cloudhandson/vpdbackoffice/
|
||||
-> 응답/오류 분류
|
||||
-> Audit 저장
|
||||
-> Thymeleaf fragment로 결과 영역 갱신
|
||||
|
||||
MCP-style reasoning
|
||||
-> McpReasoningController
|
||||
-> McpReasoningService
|
||||
-> 내부 tool registry에서 보호 객체별 ORDS 조회 도구 선택
|
||||
-> OrdsProbeService로 Bearer Token 기반 조회 실행
|
||||
-> 조회 rows/request/response 증거를 OpenAI 호환 Chat Completions API에 전달
|
||||
-> AI 응답과 도구 실행 증거를 화면에 함께 표시
|
||||
```
|
||||
|
||||
### I/O와 순수 로직 경계
|
||||
@@ -246,3 +255,19 @@ src/test/java/com/cloudhandson/vpdbackoffice/
|
||||
- 백오피스 관리자 로그인은 초기에는 local user로 둘지, 사내 인증과 연결할지 후속 결정이 필요하다.
|
||||
- 컬럼 정책을 Redaction DDL까지 자동 생성할지, 관리 테이블 저장 후 DBA 적용으로 둘지 결정이 필요하다.
|
||||
- 실제 구현 issue를 별도 Redmine 하위 이슈로 나눌지, #424를 Developer 단계로 계속 이동할지 결정이 필요하다.
|
||||
|
||||
## 13. MCP-style Reasoning 추가 설계
|
||||
|
||||
초기 구현은 외부 MCP client 라이브러리를 사용하지 않는다. 백오피스 내부에서 보호 객체를 도구 목록으로 만들고, 도구 실행은 기존 `OrdsProbeService`를 재사용한다. 이렇게 하면 실제 ORDS/VPD 결과를 그대로 증거로 삼으면서도, 추후 표준 MCP server endpoint로 분리하기 쉽다.
|
||||
|
||||
### 설정
|
||||
|
||||
| 환경변수 | 설명 |
|
||||
|---|---|
|
||||
| `BACKOFFICE_AI_ENABLED` | AI 호출 활성화 여부 |
|
||||
| `BACKOFFICE_AI_BASE_URL` | OpenAI 호환 API base URL |
|
||||
| `BACKOFFICE_AI_MODEL` | 호출할 모델명 |
|
||||
| `BACKOFFICE_AI_API_KEY` | 실제 호출에 사용할 API Key |
|
||||
| `BACKOFFICE_AI_TIMEOUT_SECONDS` | AI 호출 timeout |
|
||||
|
||||
`vpdtest1`, `vpdtest2` 같은 키 별칭은 `.env`에 보관하고, 운영자가 선택한 값을 `BACKOFFICE_AI_API_KEY`에 연결한다.
|
||||
|
||||
@@ -7,7 +7,8 @@ import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
public record BackofficeProperties(
|
||||
Security security,
|
||||
Token token,
|
||||
Ords ords
|
||||
Ords ords,
|
||||
Ai ai
|
||||
) {
|
||||
|
||||
public record Security(String adminUser, String adminPassword) {
|
||||
@@ -18,4 +19,7 @@ public record BackofficeProperties(
|
||||
|
||||
public record Ords(String baseUrl, Duration timeout) {
|
||||
}
|
||||
|
||||
public record Ai(boolean enabled, String baseUrl, String model, String apiKey, Duration timeout) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.cloudhandson.vpdbackoffice.domain.mcp;
|
||||
|
||||
public record McpReasoningCommand(
|
||||
long objectId,
|
||||
String bearerToken,
|
||||
int limit,
|
||||
String question
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.cloudhandson.vpdbackoffice.domain.mcp;
|
||||
|
||||
import com.cloudhandson.vpdbackoffice.domain.probe.ProbeResult;
|
||||
|
||||
public record McpReasoningResult(
|
||||
String modelStatus,
|
||||
String toolName,
|
||||
String answer,
|
||||
String prompt,
|
||||
String evidenceJson,
|
||||
ProbeResult probeResult
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.cloudhandson.vpdbackoffice.domain.mcp;
|
||||
|
||||
public record McpToolView(
|
||||
String name,
|
||||
String description,
|
||||
long objectId,
|
||||
String displayName,
|
||||
String ordsPath
|
||||
) {
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package com.cloudhandson.vpdbackoffice.web;
|
||||
|
||||
import com.cloudhandson.vpdbackoffice.domain.mcp.McpReasoningCommand;
|
||||
import com.cloudhandson.vpdbackoffice.domain.mcp.McpToolView;
|
||||
import com.cloudhandson.vpdbackoffice.service.McpReasoningService;
|
||||
import com.cloudhandson.vpdbackoffice.service.McpToolRegistry;
|
||||
import com.cloudhandson.vpdbackoffice.service.ProtectedObjectService;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import org.springframework.dao.DataAccessException;
|
||||
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.bind.annotation.ResponseBody;
|
||||
|
||||
@Controller
|
||||
public class McpReasoningController {
|
||||
|
||||
private final McpToolRegistry toolRegistry;
|
||||
private final McpReasoningService reasoningService;
|
||||
private final ProtectedObjectService protectedObjectService;
|
||||
|
||||
public McpReasoningController(
|
||||
McpToolRegistry toolRegistry,
|
||||
McpReasoningService reasoningService,
|
||||
ProtectedObjectService protectedObjectService
|
||||
) {
|
||||
this.toolRegistry = toolRegistry;
|
||||
this.reasoningService = reasoningService;
|
||||
this.protectedObjectService = protectedObjectService;
|
||||
}
|
||||
|
||||
@GetMapping("/mcp-reasoning")
|
||||
public String page(Model model) {
|
||||
try {
|
||||
model.addAttribute("objects", protectedObjectService.findEnabled());
|
||||
model.addAttribute("tools", toolRegistry.listTools());
|
||||
} catch (DataAccessException e) {
|
||||
RuntimeErrorMessage message = RuntimeErrorMessages.dataAccess(e);
|
||||
model.addAttribute("objects", List.of());
|
||||
model.addAttribute("tools", List.of());
|
||||
model.addAttribute("runtimeError", message);
|
||||
}
|
||||
return "mcp-reasoning";
|
||||
}
|
||||
|
||||
@GetMapping("/mcp/tools")
|
||||
@ResponseBody
|
||||
public Object tools() {
|
||||
try {
|
||||
return toolRegistry.listTools();
|
||||
} catch (DataAccessException e) {
|
||||
RuntimeErrorMessage message = RuntimeErrorMessages.dataAccess(e);
|
||||
Map<String, Object> response = new LinkedHashMap<>();
|
||||
response.put("status", "DB_NOT_AVAILABLE");
|
||||
response.put("title", message.title());
|
||||
response.put("message", message.message());
|
||||
response.put("tools", List.of());
|
||||
return response;
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/mcp-reasoning")
|
||||
public String reason(
|
||||
@RequestParam long objectId,
|
||||
@RequestParam String bearerToken,
|
||||
@RequestParam(defaultValue = "50") int limit,
|
||||
@RequestParam(defaultValue = "") String question,
|
||||
Model model
|
||||
) {
|
||||
model.addAttribute("result", reasoningService.reason(
|
||||
new McpReasoningCommand(objectId, bearerToken, limit, question)));
|
||||
return "fragments/mcp-reasoning-result :: result";
|
||||
}
|
||||
}
|
||||
@@ -30,4 +30,10 @@ backoffice:
|
||||
max-days: ${BACKOFFICE_TOKEN_MAX_DAYS:365}
|
||||
ords:
|
||||
base-url: ${BACKOFFICE_ORDS_BASE_URL:https://yh0olybn5pqce4n-d8aukro81636mon0.adb.ap-seoul-1.oraclecloudapps.com/ords}
|
||||
timeout-seconds: ${BACKOFFICE_ORDS_TIMEOUT_SECONDS:10}
|
||||
timeout: ${BACKOFFICE_ORDS_TIMEOUT_SECONDS:10}s
|
||||
ai:
|
||||
enabled: ${BACKOFFICE_AI_ENABLED:false}
|
||||
base-url: ${BACKOFFICE_AI_BASE_URL:}
|
||||
model: ${BACKOFFICE_AI_MODEL:}
|
||||
api-key: ${BACKOFFICE_AI_API_KEY:}
|
||||
timeout: ${BACKOFFICE_AI_TIMEOUT_SECONDS:30}s
|
||||
|
||||
@@ -392,6 +392,31 @@ body {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.ai-answer {
|
||||
background: var(--rw-primary-soft);
|
||||
border: 1px solid var(--rw-border);
|
||||
border-radius: 8px;
|
||||
margin: 1rem 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ai-answer h3 {
|
||||
border-bottom: 1px solid var(--rw-border);
|
||||
font-size: .9rem;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
padding: .6rem .8rem;
|
||||
}
|
||||
|
||||
.ai-answer pre {
|
||||
color: var(--rw-text);
|
||||
font-family: inherit;
|
||||
font-size: .95rem;
|
||||
margin: 0;
|
||||
padding: .85rem;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.form-grid .span-2 {
|
||||
grid-column: span 1;
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
<a class="nav-link" href="/objects">테이블/뷰</a>
|
||||
<a class="nav-link" href="/tokens">토큰</a>
|
||||
<a class="nav-link" href="/probe">ORDS 검증</a>
|
||||
<a class="nav-link" href="/mcp-reasoning">MCP Reasoning</a>
|
||||
<a class="nav-link" href="/ords-handlers">ORDS 핸들러</a>
|
||||
<a class="nav-link" href="/settings">설정</a>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
<!doctype html>
|
||||
<html lang="ko" xmlns:th="http://www.thymeleaf.org">
|
||||
<body>
|
||||
<div th:fragment="result">
|
||||
<div class="section-heading">
|
||||
<h2>Reasoning 결과</h2>
|
||||
<span class="badge"
|
||||
th:classappend="${result.modelStatus() == 'SUCCESS'} ? ' text-bg-success' : ' text-bg-warning'"
|
||||
th:text="${result.modelStatus()}">SUCCESS</span>
|
||||
</div>
|
||||
|
||||
<div class="meta-row">
|
||||
<span>Tool: <code th:text="${result.toolName()}">tool</code></span>
|
||||
<span>ORDS Status: <strong th:text="${result.probeResult().status()}">SUCCESS</strong></span>
|
||||
<span>Rows: <strong th:text="${result.probeResult().rowCount()}">0</strong></span>
|
||||
</div>
|
||||
|
||||
<section class="ai-answer">
|
||||
<h3>Answer</h3>
|
||||
<pre th:text="${result.answer()}">answer</pre>
|
||||
</section>
|
||||
|
||||
<div class="probe-exchange-grid">
|
||||
<section class="probe-exchange">
|
||||
<h3>Prompt</h3>
|
||||
<pre th:text="${result.prompt()}">prompt</pre>
|
||||
</section>
|
||||
<section class="probe-exchange">
|
||||
<h3>Tool Evidence JSON</h3>
|
||||
<pre th:text="${result.evidenceJson()}">{}</pre>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="probe-exchange-grid">
|
||||
<section class="probe-exchange">
|
||||
<h3>Request Headers</h3>
|
||||
<pre th:text="${result.probeResult().requestHeaders()} ?: '{}'">{}</pre>
|
||||
</section>
|
||||
<section class="probe-exchange">
|
||||
<h3>Response Body</h3>
|
||||
<pre th:text="${result.probeResult().responseBody()} ?: ''"></pre>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="table-responsive" th:if="${!#lists.isEmpty(result.probeResult().rows())}">
|
||||
<table class="table table-sm table-striped align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th th:each="column : ${result.probeResult().columns()}" th:text="${column}">column</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr th:each="row : ${result.probeResult().rows()}">
|
||||
<td th:each="column : ${result.probeResult().columns()}" th:text="${row.get(column)}">value</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
85
src/main/resources/templates/mcp-reasoning.html
Normal file
85
src/main/resources/templates/mcp-reasoning.html
Normal file
@@ -0,0 +1,85 @@
|
||||
<!doctype html>
|
||||
<html lang="ko" xmlns:th="http://www.thymeleaf.org">
|
||||
<head th:replace="~{fragments/layout :: head('MCP Reasoning')}"></head>
|
||||
<body>
|
||||
<nav th:replace="~{fragments/layout :: nav}"></nav>
|
||||
<main class="container py-4">
|
||||
<div class="page-title">
|
||||
<h1>MCP Reasoning</h1>
|
||||
<p>보호 객체 ORDS 호출을 내부 도구로 실행하고, 실행 증거를 OpenAI 호환 모델에 전달해 권한 결과를 해석합니다.</p>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-warning" th:if="${runtimeError}">
|
||||
<strong th:text="${runtimeError.title()}">DB 연결 설정이 필요합니다.</strong>
|
||||
<span th:text="${runtimeError.message()}">message</span>
|
||||
<div th:if="${runtimeError.showSupportCommand()}">
|
||||
<code>./run.sh backoffice-support</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="content-band">
|
||||
<div class="section-heading">
|
||||
<h2>도구 실행</h2>
|
||||
<a class="btn btn-sm btn-outline-secondary" href="/mcp/tools" target="_blank" rel="noreferrer">도구 JSON</a>
|
||||
</div>
|
||||
<form hx-post="/mcp-reasoning" hx-target="#mcp-result" hx-swap="innerHTML" class="form-grid">
|
||||
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
|
||||
<label>
|
||||
Bearer Token 원문
|
||||
<input class="form-control" name="bearerToken" type="password" autocomplete="off" required>
|
||||
</label>
|
||||
<label>
|
||||
도구
|
||||
<select class="form-select" name="objectId" required>
|
||||
<option th:each="tool : ${tools}"
|
||||
th:value="${tool.objectId()}"
|
||||
th:text="${tool.name() + ' / ' + tool.displayName()}"></option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Limit
|
||||
<input class="form-control" name="limit" type="number" min="1" max="500" value="50">
|
||||
</label>
|
||||
<label class="span-2">
|
||||
질문
|
||||
<textarea class="form-control" name="question" rows="3"
|
||||
placeholder="예: 이 토큰으로 어떤 행이 보이고 어떤 컬럼이 NULL 처리됐는지 요약해줘."></textarea>
|
||||
</label>
|
||||
<button class="btn rw-btn-primary" type="submit">Reasoning 실행</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="content-band">
|
||||
<div class="section-heading">
|
||||
<h2>등록된 도구</h2>
|
||||
<span class="badge text-bg-secondary" th:text="${#lists.size(tools)}">0</span>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Object</th>
|
||||
<th>ORDS Path</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr th:each="tool : ${tools}">
|
||||
<td><code th:text="${tool.name()}">tool</code></td>
|
||||
<td th:text="${tool.displayName()}">ADMIN.TABLE</td>
|
||||
<td><code th:text="${tool.ordsPath()}">path</code></td>
|
||||
</tr>
|
||||
<tr th:if="${#lists.isEmpty(tools)}">
|
||||
<td colspan="3" class="text-muted">등록된 보호 객체 도구가 없습니다.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="mcp-result" class="content-band">
|
||||
<div class="text-muted">Reasoning 결과가 여기에 표시됩니다.</div>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user