refs #726: add Select AI SHOWPROMPT diagnostic tool

This commit is contained in:
devmrko
2026-07-23 20:27:14 +09:00
parent f452b05209
commit 2efd1559aa
6 changed files with 213 additions and 31 deletions

View File

@@ -0,0 +1,52 @@
# Redmine #726 · Select AI SHOWPROMPT 진단 MCP
## 목표
기존 Text2SQL 생성·실행 tool과 분리된 읽기 전용 SHOWPROMPT 진단 tool을
MCP `tools/list`에 추가한다. 일반 질문 처리 Agent는 포털 allowlist를 통해
기존 tool만 사용하고, FAIL 개선 제안 버튼만 진단 tool을 직접 호출한다.
## 외부 설정
- `BACKOFFICE_MCP_SHOWPROMPT_TOOL_NAME`
- `BACKOFFICE_MCP_SHOWPROMPT_TOOL_LABEL`
- `BACKOFFICE_MCP_SHOWPROMPT_TOOL_DESCRIPTION`
기본값은 제품 중립적인 `oracle.select_ai.data_showprompt`와 업무 데이터
표현을 사용한다. 스마일게이트 운영값은 외부 env에서 고객 전용 이름으로
설정한다.
## 동작
1. HTTP Bearer Token을 기존 업무 사용자 토큰으로 검증한다.
2. prompt를 기존 4,000자 제한으로 검증한다.
3. schema-owned Select AI 연결에서
`DBMS_CLOUD_AI.GENERATE(prompt, profile, 'showprompt')`를 호출한다.
4. SQL을 실행하지 않고 다음 JSON을 반환한다.
- `status=SHOWPROMPT`
- `profile`
- `selectAiPrompt`
## 변경 함수
| 파일/함수 | 변경 |
|---|---|
| `McpProperties` | SHOWPROMPT tool 이름·label·description 외부 설정 |
| `SelectAiService.generatePrompt` | 인증·prompt 검증 후 SHOWPROMPT 반환 |
| `SelectAiService.generate` | `showsql/showprompt` action을 bind하는 공통 생성 함수 |
| `McpSseService.toolsListResult` | Text2SQL과 SHOWPROMPT 두 tool 등록 |
| `McpSseService.toolsCallResult` | exact tool name에 따라 query/diagnostic 분기 |
## 안전 조건
- SHOWPROMPT tool은 생성 SQL을 실행하지 않는다.
- 임의 action 인자를 사용자에게 받지 않는다.
- 응답에 Bearer Token이나 DB 연결 정보를 포함하지 않는다.
- 기존 Text2SQL tool 이름과 계약은 유지한다.
## 테스트
- tools/list에 두 tool과 각 prompt schema가 존재한다.
- Text2SQL 호출은 기존 generate-and-execute 경로를 유지한다.
- SHOWPROMPT 호출은 generatePrompt만 실행한다.
- 누락/비활성 Bearer Token은 기존과 동일하게 거절한다.

View File

@@ -8,7 +8,10 @@ public record McpProperties(
String toolName, String toolName,
String toolLabel, String toolLabel,
String toolDescription, String toolDescription,
String promptDescription String promptDescription,
String showpromptToolName,
String showpromptToolLabel,
String showpromptToolDescription
) { ) {
private static final String DEFAULT_TOOL_NAME = "oracle.select_ai.data_text2sql"; private static final String DEFAULT_TOOL_NAME = "oracle.select_ai.data_text2sql";
@@ -18,6 +21,13 @@ public record McpProperties(
+ "생성 SQL과 최대 100건의 조회 결과를 함께 반환하며 DDL/DML/잠금/패키지 호출은 실행하지 않습니다."; + "생성 SQL과 최대 100건의 조회 결과를 함께 반환하며 DDL/DML/잠금/패키지 호출은 실행하지 않습니다.";
private static final String DEFAULT_PROMPT_DESCRIPTION = private static final String DEFAULT_PROMPT_DESCRIPTION =
"업무 데이터에서 조회할 내용을 자연어로 입력합니다."; "업무 데이터에서 조회할 내용을 자연어로 입력합니다.";
private static final String DEFAULT_SHOWPROMPT_TOOL_NAME =
"oracle.select_ai.data_showprompt";
private static final String DEFAULT_SHOWPROMPT_TOOL_LABEL =
"업무 데이터 SHOWPROMPT";
private static final String DEFAULT_SHOWPROMPT_TOOL_DESCRIPTION =
"Select AI가 SQL 생성에 사용한 prompt를 조회하는 읽기 전용 진단 도구입니다. "
+ "생성 SQL이나 데이터 조회 SQL은 실행하지 않습니다.";
public String resolvedToolName() { public String resolvedToolName() {
return requiredOrDefault(toolName, DEFAULT_TOOL_NAME); return requiredOrDefault(toolName, DEFAULT_TOOL_NAME);
@@ -35,6 +45,18 @@ public record McpProperties(
return requiredOrDefault(promptDescription, DEFAULT_PROMPT_DESCRIPTION); return requiredOrDefault(promptDescription, DEFAULT_PROMPT_DESCRIPTION);
} }
public String resolvedShowpromptToolName() {
return requiredOrDefault(showpromptToolName, DEFAULT_SHOWPROMPT_TOOL_NAME);
}
public String resolvedShowpromptToolLabel() {
return requiredOrDefault(showpromptToolLabel, DEFAULT_SHOWPROMPT_TOOL_LABEL);
}
public String resolvedShowpromptToolDescription() {
return requiredOrDefault(showpromptToolDescription, DEFAULT_SHOWPROMPT_TOOL_DESCRIPTION);
}
private String requiredOrDefault(String value, String fallback) { private String requiredOrDefault(String value, String fallback) {
return value == null || value.isBlank() ? fallback : value.trim(); return value == null || value.isBlank() ? fallback : value.trim();
} }

View File

@@ -10,11 +10,11 @@ import com.fasterxml.jackson.databind.node.ObjectNode;
import java.util.List; import java.util.List;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
/** MCP boundary exposing a configured Select AI generation and read-only execution tool. */ /** MCP boundary exposing Select AI query and read-only prompt diagnostic tools. */
@Service @Service
public class McpSseService { public class McpSseService {
private static final String SELECT_AI_VPD_QUERY_PATH = "/mcp (tools/call)"; private static final String SELECT_AI_TOOL_PATH = "/mcp (tools/call)";
private final SelectAiService selectAiService; private final SelectAiService selectAiService;
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
@@ -67,9 +67,9 @@ public class McpSseService {
return response; return response;
} }
/** The only tool registered by this MCP server. */ /** Tools registered by this MCP server. */
public List<McpToolView> registeredTools() { public List<McpToolView> registeredTools() {
return List.of(selectAiVpdQueryView()); return List.of(selectAiQueryView(), selectAiShowpromptView());
} }
private ObjectNode initializeResult(String contextPath) { private ObjectNode initializeResult(String contextPath) {
@@ -88,15 +88,16 @@ public class McpSseService {
private ObjectNode toolsListResult() { private ObjectNode toolsListResult() {
ObjectNode result = objectMapper.createObjectNode(); ObjectNode result = objectMapper.createObjectNode();
ArrayNode tools = objectMapper.createArrayNode(); ArrayNode tools = objectMapper.createArrayNode();
tools.add(selectAiVpdQueryTool()); tools.add(toolDefinition(selectAiQueryView()));
tools.add(toolDefinition(selectAiShowpromptView()));
result.set("tools", tools); result.set("tools", tools);
return result; return result;
} }
private ObjectNode selectAiVpdQueryTool() { private ObjectNode toolDefinition(McpToolView toolView) {
ObjectNode item = objectMapper.createObjectNode(); ObjectNode item = objectMapper.createObjectNode();
item.put("name", toolName()); item.put("name", toolView.name());
item.put("description", selectAiVpdQueryView().description()); item.put("description", toolView.description());
ObjectNode schema = objectMapper.createObjectNode(); ObjectNode schema = objectMapper.createObjectNode();
schema.put("type", "object"); schema.put("type", "object");
@@ -118,9 +119,11 @@ public class McpSseService {
} }
private ObjectNode toolsCallResult(JsonNode params, String vpdBearerToken) { private ObjectNode toolsCallResult(JsonNode params, String vpdBearerToken) {
String toolName = params.path("name").asText(""); String calledToolName = params.path("name").asText("");
if (!toolName().equals(toolName)) { boolean queryTool = toolName().equals(calledToolName);
throw new AppException("등록되지 않은 MCP tool입니다: " + toolName); boolean showpromptTool = showpromptToolName().equals(calledToolName);
if (!queryTool && !showpromptTool) {
throw new AppException("등록되지 않은 MCP tool입니다: " + calledToolName);
} }
JsonNode arguments = params.path("arguments"); JsonNode arguments = params.path("arguments");
@@ -130,15 +133,18 @@ public class McpSseService {
} }
JsonNode response; JsonNode response;
try { try {
response = selectAiService.generateAndExecute(token, arguments.path("prompt").asText("")); String prompt = arguments.path("prompt").asText("");
response = queryTool
? selectAiService.generateAndExecute(token, prompt)
: selectAiService.generatePrompt(token, prompt);
} catch (VpdTokenAccessDeniedException ignored) { } catch (VpdTokenAccessDeniedException ignored) {
return tokenAccessDeniedResult(); return tokenAccessDeniedResult();
} }
ObjectNode payload = objectMapper.createObjectNode(); ObjectNode payload = objectMapper.createObjectNode();
payload.put("toolName", toolName()); payload.put("toolName", calledToolName);
payload.put("profile", selectAiProfile()); payload.put("profile", selectAiProfile());
payload.put("ordsPath", SELECT_AI_VPD_QUERY_PATH); payload.put("ordsPath", SELECT_AI_TOOL_PATH);
payload.set("response", response); payload.set("response", response);
ObjectNode result = objectMapper.createObjectNode(); ObjectNode result = objectMapper.createObjectNode();
@@ -168,14 +174,25 @@ public class McpSseService {
return result; return result;
} }
private McpToolView selectAiVpdQueryView() { private McpToolView selectAiQueryView() {
String profile = selectAiProfile(); String profile = selectAiProfile();
return new McpToolView( return new McpToolView(
toolName(), toolName(),
profile + " 프로파일로 " + toolDescription(), profile + " 프로파일로 " + toolDescription(),
-1L, -1L,
toolLabel(), toolLabel(),
SELECT_AI_VPD_QUERY_PATH SELECT_AI_TOOL_PATH
);
}
private McpToolView selectAiShowpromptView() {
String profile = selectAiProfile();
return new McpToolView(
showpromptToolName(),
profile + " 프로파일로 " + showpromptToolDescription(),
-1L,
showpromptToolLabel(),
SELECT_AI_TOOL_PATH
); );
} }
@@ -205,6 +222,24 @@ public class McpSseService {
return mcpProperties == null ? "업무 데이터에서 조회할 내용을 자연어로 입력합니다." : mcpProperties.resolvedPromptDescription(); return mcpProperties == null ? "업무 데이터에서 조회할 내용을 자연어로 입력합니다." : mcpProperties.resolvedPromptDescription();
} }
private String showpromptToolName() {
return mcpProperties == null
? "oracle.select_ai.data_showprompt"
: mcpProperties.resolvedShowpromptToolName();
}
private String showpromptToolLabel() {
return mcpProperties == null
? "업무 데이터 SHOWPROMPT"
: mcpProperties.resolvedShowpromptToolLabel();
}
private String showpromptToolDescription() {
return mcpProperties == null
? "Select AI가 SQL 생성에 사용한 prompt를 조회하는 읽기 전용 진단 도구입니다."
: mcpProperties.resolvedShowpromptToolDescription();
}
private String pretty(Object value) { private String pretty(Object value) {
try { try {
return objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(value); return objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(value);

View File

@@ -55,14 +55,9 @@ public class SelectAiService {
public JsonNode generateAndExecute(String bearerToken, String prompt) { public JsonNode generateAndExecute(String bearerToken, String prompt) {
requireActiveToken(bearerToken); requireActiveToken(bearerToken);
String normalizedPrompt = requiredPrompt(prompt); String normalizedPrompt = requiredPrompt(prompt);
BackofficeProperties.SelectAi selectAi = properties == null ? null : properties.selectAi(); BackofficeProperties.SelectAi selectAi = requiredSelectAi();
if (selectAi == null || !selectAi.configured()) {
throw new AppException("Select AI 연결 설정이 필요합니다. "
+ "BACKOFFICE_SELECT_AI_DB_URL, BACKOFFICE_SELECT_AI_DB_USERNAME, "
+ "BACKOFFICE_SELECT_AI_DB_PASSWORD를 확인하세요.");
}
String generatedSql = generate(selectAi, normalizedPrompt); String generatedSql = generate(selectAi, normalizedPrompt, "showsql");
String normalizedSql = validateReadOnlySql(generatedSql); String normalizedSql = validateReadOnlySql(generatedSql);
QueryExecution execution = executeReadOnly(selectAi, normalizedSql); QueryExecution execution = executeReadOnly(selectAi, normalizedSql);
ObjectNode response = objectMapper.createObjectNode(); ObjectNode response = objectMapper.createObjectNode();
@@ -79,6 +74,30 @@ public class SelectAiService {
return response; return response;
} }
/** Returns the prompt Select AI assembled for SQL generation without executing generated SQL. */
public JsonNode generatePrompt(String bearerToken, String prompt) {
requireActiveToken(bearerToken);
String normalizedPrompt = requiredPrompt(prompt);
BackofficeProperties.SelectAi selectAi = requiredSelectAi();
String selectAiPrompt = generate(selectAi, normalizedPrompt, "showprompt");
ObjectNode response = objectMapper.createObjectNode();
response.put("status", "SHOWPROMPT");
response.put("profile", selectAi.profile());
response.put("selectAiPrompt", selectAiPrompt);
return response;
}
private BackofficeProperties.SelectAi requiredSelectAi() {
BackofficeProperties.SelectAi selectAi = properties == null ? null : properties.selectAi();
if (selectAi == null || !selectAi.configured()) {
throw new AppException("Select AI 연결 설정이 필요합니다. "
+ "BACKOFFICE_SELECT_AI_DB_URL, BACKOFFICE_SELECT_AI_DB_USERNAME, "
+ "BACKOFFICE_SELECT_AI_DB_PASSWORD를 확인하세요.");
}
return selectAi;
}
private void requireActiveToken(String bearerToken) { private void requireActiveToken(String bearerToken) {
if (bearerToken == null || bearerToken.isBlank()) { if (bearerToken == null || bearerToken.isBlank()) {
throw new VpdTokenAccessDeniedException(); throw new VpdTokenAccessDeniedException();
@@ -101,23 +120,29 @@ public class SelectAiService {
return normalized; return normalized;
} }
private String generate(BackofficeProperties.SelectAi selectAi, String prompt) { private String generate(
String sql = "SELECT DBMS_CLOUD_AI.GENERATE(?, ?, 'showsql') FROM dual"; BackofficeProperties.SelectAi selectAi,
String prompt,
String action
) {
String sql = "SELECT DBMS_CLOUD_AI.GENERATE(?, ?, ?) FROM dual";
try (Connection connection = DriverManager.getConnection( try (Connection connection = DriverManager.getConnection(
selectAi.dbUrl(), selectAi.dbUsername(), selectAi.dbPassword()); selectAi.dbUrl(), selectAi.dbUsername(), selectAi.dbPassword());
PreparedStatement statement = connection.prepareStatement(sql)) { PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setString(1, prompt); statement.setString(1, prompt);
statement.setString(2, selectAi.profile()); statement.setString(2, selectAi.profile());
statement.setString(3, action);
try (ResultSet resultSet = statement.executeQuery()) { try (ResultSet resultSet = statement.executeQuery()) {
if (!resultSet.next() || resultSet.getString(1) == null) { if (!resultSet.next() || resultSet.getString(1) == null) {
throw new AppException("Select AI가 생성 SQL을 반환하지 않았습니다."); throw new AppException("Select AI가 " + action.toUpperCase() + " 결과를 반환하지 않았습니다.");
} }
return resultSet.getString(1); return resultSet.getString(1);
} }
} catch (AppException exception) { } catch (AppException exception) {
throw exception; throw exception;
} catch (Exception exception) { } catch (Exception exception) {
throw new AppException("Select AI SHOWSQL 생성 실패: " + exception.getMessage()); throw new AppException("Select AI " + action.toUpperCase() + " 생성 실패: "
+ exception.getMessage());
} }
} }

View File

@@ -87,6 +87,9 @@ backoffice:
tool-label: ${BACKOFFICE_MCP_TOOL_LABEL:업무 데이터 Text2SQL} tool-label: ${BACKOFFICE_MCP_TOOL_LABEL:업무 데이터 Text2SQL}
tool-description: ${BACKOFFICE_MCP_TOOL_DESCRIPTION:승인된 업무 데이터용 읽기 전용 SELECT/WITH SQL을 생성하고 검증 후 읽기 전용 트랜잭션에서 실행합니다. 생성 SQL과 최대 100건의 조회 결과를 함께 반환하며 DDL/DML/잠금/패키지 호출은 실행하지 않습니다.} tool-description: ${BACKOFFICE_MCP_TOOL_DESCRIPTION:승인된 업무 데이터용 읽기 전용 SELECT/WITH SQL을 생성하고 검증 후 읽기 전용 트랜잭션에서 실행합니다. 생성 SQL과 최대 100건의 조회 결과를 함께 반환하며 DDL/DML/잠금/패키지 호출은 실행하지 않습니다.}
prompt-description: ${BACKOFFICE_MCP_PROMPT_DESCRIPTION:업무 데이터에서 조회할 내용을 자연어로 입력합니다.} prompt-description: ${BACKOFFICE_MCP_PROMPT_DESCRIPTION:업무 데이터에서 조회할 내용을 자연어로 입력합니다.}
showprompt-tool-name: ${BACKOFFICE_MCP_SHOWPROMPT_TOOL_NAME:oracle.select_ai.data_showprompt}
showprompt-tool-label: ${BACKOFFICE_MCP_SHOWPROMPT_TOOL_LABEL:업무 데이터 SHOWPROMPT}
showprompt-tool-description: ${BACKOFFICE_MCP_SHOWPROMPT_TOOL_DESCRIPTION:Select AI가 SQL 생성에 사용한 prompt를 조회하는 읽기 전용 진단 도구입니다. 생성 SQL이나 데이터 조회 SQL은 실행하지 않습니다.}
masking: masking:
policies: ${BACKOFFICE_MASKING_POLICIES:} policies: ${BACKOFFICE_MASKING_POLICIES:}
security-sql-scripts: security-sql-scripts:

View File

@@ -26,16 +26,19 @@ class McpSseServiceTest {
"oracle.select_ai.test_data_text2sql", "oracle.select_ai.test_data_text2sql",
"테스트 데이터 Text2SQL", "테스트 데이터 Text2SQL",
"테스트 데이터용 읽기 전용 SQL을 생성하고 실행합니다.", "테스트 데이터용 읽기 전용 SQL을 생성하고 실행합니다.",
"테스트 데이터의 조회 내용을 입력합니다." "테스트 데이터의 조회 내용을 입력합니다.",
"oracle.select_ai.test_data_showprompt",
"테스트 데이터 SHOWPROMPT",
"테스트 데이터의 Select AI prompt를 조회합니다."
) )
); );
@Test @Test
void listsOnlyVpdSelectAiToolWithPromptInput() { void listsQueryAndShowpromptToolsWithPromptInput() {
ObjectNode response = service.handle("default", request(1, "tools/list")); ObjectNode response = service.handle("default", request(1, "tools/list"));
var tools = response.path("result").path("tools"); var tools = response.path("result").path("tools");
assertThat(tools).hasSize(1); assertThat(tools).hasSize(2);
var selectAi = tools.get(0); var selectAi = tools.get(0);
assertThat(selectAi.path("name").asText()).isEqualTo("oracle.select_ai.test_data_text2sql"); assertThat(selectAi.path("name").asText()).isEqualTo("oracle.select_ai.test_data_text2sql");
assertThat(selectAi.path("description").asText()).contains("SGMP_POC_OCI_GPT54MINI"); assertThat(selectAi.path("description").asText()).contains("SGMP_POC_OCI_GPT54MINI");
@@ -45,6 +48,36 @@ class McpSseServiceTest {
assertThat(selectAi.path("inputSchema").path("properties").has("bearerToken")).isFalse(); assertThat(selectAi.path("inputSchema").path("properties").has("bearerToken")).isFalse();
assertThat(selectAi.path("inputSchema").path("properties").has("limit")).isFalse(); assertThat(selectAi.path("inputSchema").path("properties").has("limit")).isFalse();
assertThat(selectAi.path("inputSchema").path("properties").has("conversationId")).isFalse(); assertThat(selectAi.path("inputSchema").path("properties").has("conversationId")).isFalse();
var showprompt = tools.get(1);
assertThat(showprompt.path("name").asText())
.isEqualTo("oracle.select_ai.test_data_showprompt");
assertThat(showprompt.path("description").asText())
.contains("SGMP_POC_OCI_GPT54MINI")
.contains("Select AI prompt");
assertThat(showprompt.path("inputSchema").path("required"))
.extracting(node -> node.asText())
.contains("prompt");
}
@Test
void callsShowpromptWithoutExecutingGeneratedSql() {
ObjectNode request = request(4, "tools/call");
ObjectNode params = (ObjectNode) request.putObject("params");
params.put("name", "oracle.select_ai.test_data_showprompt");
params.putObject("arguments").put("prompt", "버블리즈 유저 마스터 조회해줘.");
ObjectNode response = service.handle("default", request, "user-bearer");
CapturingSelectAiService agentService = (CapturingSelectAiService) selectAiService;
assertThat(agentService.bearerToken).isEqualTo("user-bearer");
assertThat(agentService.prompt).isEqualTo("버블리즈 유저 마스터 조회해줘.");
assertThat(agentService.showpromptCalled).isTrue();
assertThat(response.path("result").path("isError").asBoolean()).isFalse();
assertThat(response.path("result").path("content").get(0).path("text").asText())
.contains("oracle.select_ai.test_data_showprompt")
.contains("SHOWPROMPT")
.contains("assembled Select AI prompt");
} }
@Test @Test
@@ -99,6 +132,7 @@ class McpSseServiceTest {
private String bearerToken; private String bearerToken;
private String prompt; private String prompt;
private boolean showpromptCalled;
private CapturingSelectAiService() { private CapturingSelectAiService() {
super(null, null, null, new ObjectMapper()); super(null, null, null, new ObjectMapper());
@@ -117,5 +151,16 @@ class McpSseServiceTest {
response.putArray("items").addObject().put("RESULT", 1); response.putArray("items").addObject().put("RESULT", 1);
return response; return response;
} }
@Override
public JsonNode generatePrompt(String bearerToken, String prompt) {
this.bearerToken = bearerToken;
this.prompt = prompt;
this.showpromptCalled = true;
return new ObjectMapper().createObjectNode()
.put("profile", "SGMP_POC_OCI_GPT54MINI")
.put("status", "SHOWPROMPT")
.put("selectAiPrompt", "assembled Select AI prompt");
}
} }
} }