diff --git a/.env.example b/.env.example index 7df8c81..34366b4 100644 --- a/.env.example +++ b/.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..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명의 데모 유저: diff --git a/docs/design/424-spring-boot-vpd-backoffice/README.md b/docs/design/424-spring-boot-vpd-backoffice/README.md index 7a69b3c..9440080 100644 --- a/docs/design/424-spring-boot-vpd-backoffice/README.md +++ b/docs/design/424-spring-boot-vpd-backoffice/README.md @@ -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`에 연결한다. diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/config/BackofficeProperties.java b/src/main/java/com/cloudhandson/vpdbackoffice/config/BackofficeProperties.java index 87145dc..402f85b 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/config/BackofficeProperties.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/config/BackofficeProperties.java @@ -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) { + } } diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/mcp/McpReasoningCommand.java b/src/main/java/com/cloudhandson/vpdbackoffice/domain/mcp/McpReasoningCommand.java new file mode 100644 index 0000000..02086b9 --- /dev/null +++ b/src/main/java/com/cloudhandson/vpdbackoffice/domain/mcp/McpReasoningCommand.java @@ -0,0 +1,9 @@ +package com.cloudhandson.vpdbackoffice.domain.mcp; + +public record McpReasoningCommand( + long objectId, + String bearerToken, + int limit, + String question +) { +} diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/mcp/McpReasoningResult.java b/src/main/java/com/cloudhandson/vpdbackoffice/domain/mcp/McpReasoningResult.java new file mode 100644 index 0000000..3ad1b0f --- /dev/null +++ b/src/main/java/com/cloudhandson/vpdbackoffice/domain/mcp/McpReasoningResult.java @@ -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 +) { +} diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/mcp/McpToolView.java b/src/main/java/com/cloudhandson/vpdbackoffice/domain/mcp/McpToolView.java new file mode 100644 index 0000000..92c1666 --- /dev/null +++ b/src/main/java/com/cloudhandson/vpdbackoffice/domain/mcp/McpToolView.java @@ -0,0 +1,10 @@ +package com.cloudhandson.vpdbackoffice.domain.mcp; + +public record McpToolView( + String name, + String description, + long objectId, + String displayName, + String ordsPath +) { +} diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/McpReasoningService.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/McpReasoningService.java new file mode 100644 index 0000000..8898d49 --- /dev/null +++ b/src/main/java/com/cloudhandson/vpdbackoffice/service/McpReasoningService.java @@ -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(); + } +} diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/McpToolRegistry.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/McpToolRegistry.java new file mode 100644 index 0000000..59db854 --- /dev/null +++ b/src/main/java/com/cloudhandson/vpdbackoffice/service/McpToolRegistry.java @@ -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 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_]+", "_"); + } +} diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/OpenAiCompatibleClient.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/OpenAiCompatibleClient.java new file mode 100644 index 0000000..ad9063f --- /dev/null +++ b/src/main/java/com/cloudhandson/vpdbackoffice/service/OpenAiCompatibleClient.java @@ -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 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(); + } +} diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/web/McpReasoningController.java b/src/main/java/com/cloudhandson/vpdbackoffice/web/McpReasoningController.java new file mode 100644 index 0000000..9617e21 --- /dev/null +++ b/src/main/java/com/cloudhandson/vpdbackoffice/web/McpReasoningController.java @@ -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 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"; + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 29adfb1..8e92217 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -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 diff --git a/src/main/resources/static/css/app.css b/src/main/resources/static/css/app.css index f00066b..cba212c 100644 --- a/src/main/resources/static/css/app.css +++ b/src/main/resources/static/css/app.css @@ -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; diff --git a/src/main/resources/templates/fragments/layout.html b/src/main/resources/templates/fragments/layout.html index e18bcfa..90c06c9 100644 --- a/src/main/resources/templates/fragments/layout.html +++ b/src/main/resources/templates/fragments/layout.html @@ -21,6 +21,7 @@ 테이블/뷰 토큰 ORDS 검증 + MCP Reasoning ORDS 핸들러 설정 diff --git a/src/main/resources/templates/fragments/mcp-reasoning-result.html b/src/main/resources/templates/fragments/mcp-reasoning-result.html new file mode 100644 index 0000000..0814dda --- /dev/null +++ b/src/main/resources/templates/fragments/mcp-reasoning-result.html @@ -0,0 +1,61 @@ + + + +
+
+

Reasoning 결과

+ SUCCESS +
+ +
+ Tool: tool + ORDS Status: SUCCESS + Rows: 0 +
+ +
+

Answer

+
answer
+
+ +
+
+

Prompt

+
prompt
+
+
+

Tool Evidence JSON

+
{}
+
+
+ +
+
+

Request Headers

+
{}
+
+
+

Response Body

+

+    
+
+ +
+ + + + + + + + + + + +
column
value
+
+
+ + diff --git a/src/main/resources/templates/mcp-reasoning.html b/src/main/resources/templates/mcp-reasoning.html new file mode 100644 index 0000000..89e093d --- /dev/null +++ b/src/main/resources/templates/mcp-reasoning.html @@ -0,0 +1,85 @@ + + + + + +
+
+

MCP Reasoning

+

보호 객체 ORDS 호출을 내부 도구로 실행하고, 실행 증거를 OpenAI 호환 모델에 전달해 권한 결과를 해석합니다.

+
+ +
+ DB 연결 설정이 필요합니다. + message +
+ ./run.sh backoffice-support +
+
+ +
+
+

도구 실행

+ 도구 JSON +
+
+ + + + + + +
+
+ +
+
+

등록된 도구

+ 0 +
+
+ + + + + + + + + + + + + + + + + + +
NameObjectORDS Path
toolADMIN.TABLEpath
등록된 보호 객체 도구가 없습니다.
+
+
+ +
+
Reasoning 결과가 여기에 표시됩니다.
+
+
+ +