refs #703: execute validated Smilegate Text2SQL
This commit is contained in:
@@ -8,7 +8,7 @@ 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 the Smilegate game-data Select AI SHOWSQL tool. */
|
/** MCP boundary exposing the Smilegate game-data Select AI generation and read-only execution tool. */
|
||||||
@Service
|
@Service
|
||||||
public class McpSseService {
|
public class McpSseService {
|
||||||
|
|
||||||
@@ -17,7 +17,7 @@ public class McpSseService {
|
|||||||
private static final String SELECT_AI_VPD_QUERY_PROFILE = "SGMP_POC_HAIKU45";
|
private static final String SELECT_AI_VPD_QUERY_PROFILE = "SGMP_POC_HAIKU45";
|
||||||
private static final McpToolView SELECT_AI_VPD_QUERY_VIEW = new McpToolView(
|
private static final McpToolView SELECT_AI_VPD_QUERY_VIEW = new McpToolView(
|
||||||
SELECT_AI_VPD_QUERY_TOOL,
|
SELECT_AI_VPD_QUERY_TOOL,
|
||||||
"SGMP_POC_HAIKU45 프로파일로 게임 로그·서비스 데이터용 읽기 전용 SELECT/WITH SQL을 생성합니다. 생성 SQL은 자동 실행하지 않으며 테이블·컬럼 comment, annotation, constraint를 참고합니다.",
|
"SGMP_POC_HAIKU45 프로파일로 게임 로그·서비스 데이터용 읽기 전용 SELECT/WITH SQL을 생성하고, 검증 후 읽기 전용 트랜잭션에서 실행합니다. 생성 SQL과 최대 100건의 조회 결과를 함께 반환하며 DDL/DML/잠금/패키지 호출은 실행하지 않습니다.",
|
||||||
-1L,
|
-1L,
|
||||||
"Smilegate 게임 데이터 Text2SQL",
|
"Smilegate 게임 데이터 Text2SQL",
|
||||||
SELECT_AI_VPD_QUERY_PATH
|
SELECT_AI_VPD_QUERY_PATH
|
||||||
@@ -131,7 +131,7 @@ public class McpSseService {
|
|||||||
}
|
}
|
||||||
JsonNode response;
|
JsonNode response;
|
||||||
try {
|
try {
|
||||||
response = smilegateSelectAiService.generateShowSql(token, arguments.path("prompt").asText(""));
|
response = smilegateSelectAiService.generateAndExecute(token, arguments.path("prompt").asText(""));
|
||||||
} catch (VpdTokenAccessDeniedException ignored) {
|
} catch (VpdTokenAccessDeniedException ignored) {
|
||||||
return tokenAccessDeniedResult();
|
return tokenAccessDeniedResult();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,25 +4,36 @@ import com.cloudhandson.vpdbackoffice.config.BackofficeProperties;
|
|||||||
import com.cloudhandson.vpdbackoffice.domain.token.BearerTokenRecord;
|
import com.cloudhandson.vpdbackoffice.domain.token.BearerTokenRecord;
|
||||||
import com.fasterxml.jackson.databind.JsonNode;
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.fasterxml.jackson.databind.node.ArrayNode;
|
||||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.math.BigInteger;
|
||||||
import java.sql.Connection;
|
import java.sql.Connection;
|
||||||
import java.sql.DriverManager;
|
import java.sql.DriverManager;
|
||||||
import java.sql.PreparedStatement;
|
import java.sql.PreparedStatement;
|
||||||
import java.sql.ResultSet;
|
import java.sql.ResultSet;
|
||||||
|
import java.sql.ResultSetMetaData;
|
||||||
|
import java.sql.Statement;
|
||||||
import java.time.Clock;
|
import java.time.Clock;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.time.ZoneId;
|
import java.time.ZoneId;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generates reviewed read-only SQL through the schema-owned Smilegate Select AI profile.
|
* Generates and executes bounded read-only SQL through the schema-owned Smilegate Select AI profile.
|
||||||
* The generated SQL is never executed by this service.
|
|
||||||
*/
|
*/
|
||||||
@Service
|
@Service
|
||||||
public class SmilegateSelectAiService {
|
public class SmilegateSelectAiService {
|
||||||
|
|
||||||
private static final int MAX_PROMPT_LENGTH = 4_000;
|
private static final int MAX_PROMPT_LENGTH = 4_000;
|
||||||
private static final String ACTION_SHOWSQL = "showsql";
|
private static final int MAX_RESULT_ROWS = 100;
|
||||||
|
private static final int QUERY_TIMEOUT_SECONDS = 30;
|
||||||
|
private static final Pattern UNSAFE_SQL = Pattern.compile(
|
||||||
|
"(?is)\\b(?:insert|update|delete|merge|alter|drop|create|truncate|grant|revoke|"
|
||||||
|
+ "commit|rollback|savepoint|lock|call|exec(?:ute)?|begin|declare|for\\s+update|"
|
||||||
|
+ "dbms_[a-z0-9_]*|utl_[a-z0-9_]*|sys\\s*\\.)\\b"
|
||||||
|
);
|
||||||
|
|
||||||
private final BackofficeProperties properties;
|
private final BackofficeProperties properties;
|
||||||
private final BearerTokenService bearerTokenService;
|
private final BearerTokenService bearerTokenService;
|
||||||
@@ -41,7 +52,7 @@ public class SmilegateSelectAiService {
|
|||||||
this.objectMapper = objectMapper;
|
this.objectMapper = objectMapper;
|
||||||
}
|
}
|
||||||
|
|
||||||
public JsonNode generateShowSql(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 = properties == null ? null : properties.selectAi();
|
||||||
@@ -53,12 +64,18 @@ public class SmilegateSelectAiService {
|
|||||||
|
|
||||||
String generatedSql = generate(selectAi, normalizedPrompt);
|
String generatedSql = generate(selectAi, normalizedPrompt);
|
||||||
String normalizedSql = validateReadOnlySql(generatedSql);
|
String normalizedSql = validateReadOnlySql(generatedSql);
|
||||||
|
QueryExecution execution = executeReadOnly(selectAi, normalizedSql);
|
||||||
ObjectNode response = objectMapper.createObjectNode();
|
ObjectNode response = objectMapper.createObjectNode();
|
||||||
response.put("status", "SHOWSQL");
|
response.put("status", "SHOWSQL_AND_EXECUTED");
|
||||||
response.put("profile", selectAi.profile());
|
response.put("profile", selectAi.profile());
|
||||||
response.put("generatedSql", normalizedSql);
|
response.put("generatedSql", normalizedSql);
|
||||||
response.put("execution", "NOT_EXECUTED");
|
response.put("execution", "READ_ONLY_EXECUTED");
|
||||||
response.put("nextStep", "생성 SQL을 검토한 뒤 Database Actions 또는 승인된 실행 경로에서 실행하세요.");
|
response.put("rowCount", execution.items().size());
|
||||||
|
response.put("truncated", execution.truncated());
|
||||||
|
response.set("items", execution.items());
|
||||||
|
response.put("nextStep", execution.truncated()
|
||||||
|
? "최초 " + MAX_RESULT_ROWS + "건만 반환했습니다. 생성 SQL로 전체 결과를 확인할 수 있습니다."
|
||||||
|
: "생성 SQL을 읽기 전용으로 실행한 결과입니다.");
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,6 +121,68 @@ public class SmilegateSelectAiService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private QueryExecution executeReadOnly(BackofficeProperties.SelectAi selectAi, String generatedSql) {
|
||||||
|
ArrayNode items = objectMapper.createArrayNode();
|
||||||
|
boolean truncated = false;
|
||||||
|
try (Connection connection = DriverManager.getConnection(
|
||||||
|
selectAi.dbUrl(), selectAi.dbUsername(), selectAi.dbPassword());
|
||||||
|
Statement transaction = connection.createStatement()) {
|
||||||
|
connection.setReadOnly(true);
|
||||||
|
transaction.execute("SET TRANSACTION READ ONLY");
|
||||||
|
try (PreparedStatement statement = connection.prepareStatement(generatedSql)) {
|
||||||
|
statement.setQueryTimeout(QUERY_TIMEOUT_SECONDS);
|
||||||
|
statement.setFetchSize(MAX_RESULT_ROWS + 1);
|
||||||
|
statement.setMaxRows(MAX_RESULT_ROWS + 1);
|
||||||
|
try (ResultSet resultSet = statement.executeQuery()) {
|
||||||
|
ResultSetMetaData metadata = resultSet.getMetaData();
|
||||||
|
while (resultSet.next()) {
|
||||||
|
if (items.size() >= MAX_RESULT_ROWS) {
|
||||||
|
truncated = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
ObjectNode row = items.addObject();
|
||||||
|
for (int columnIndex = 1; columnIndex <= metadata.getColumnCount(); columnIndex++) {
|
||||||
|
String column = metadata.getColumnLabel(columnIndex);
|
||||||
|
if (column == null || column.isBlank()) {
|
||||||
|
column = metadata.getColumnName(columnIndex);
|
||||||
|
}
|
||||||
|
putResultValue(row, column, resultSet.getObject(columnIndex));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
connection.rollback();
|
||||||
|
}
|
||||||
|
} catch (Exception exception) {
|
||||||
|
throw new AppException("Smilegate Select AI 생성 SQL 실행 실패: " + exception.getMessage());
|
||||||
|
}
|
||||||
|
return new QueryExecution(items, truncated);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void putResultValue(ObjectNode row, String column, Object value) {
|
||||||
|
if (value == null) {
|
||||||
|
row.putNull(column);
|
||||||
|
} else if (value instanceof BigDecimal number) {
|
||||||
|
row.put(column, number);
|
||||||
|
} else if (value instanceof BigInteger number) {
|
||||||
|
row.put(column, number);
|
||||||
|
} else if (value instanceof Integer number) {
|
||||||
|
row.put(column, number);
|
||||||
|
} else if (value instanceof Long number) {
|
||||||
|
row.put(column, number);
|
||||||
|
} else if (value instanceof Short number) {
|
||||||
|
row.put(column, number);
|
||||||
|
} else if (value instanceof Float number) {
|
||||||
|
row.put(column, number);
|
||||||
|
} else if (value instanceof Double number) {
|
||||||
|
row.put(column, number);
|
||||||
|
} else if (value instanceof Boolean bool) {
|
||||||
|
row.put(column, bool);
|
||||||
|
} else {
|
||||||
|
row.put(column, String.valueOf(value));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private String validateReadOnlySql(String generatedSql) {
|
private String validateReadOnlySql(String generatedSql) {
|
||||||
String normalized = generatedSql == null ? "" : generatedSql.trim();
|
String normalized = generatedSql == null ? "" : generatedSql.trim();
|
||||||
if (normalized.startsWith("```")) {
|
if (normalized.startsWith("```")) {
|
||||||
@@ -120,6 +199,12 @@ public class SmilegateSelectAiService {
|
|||||||
if (normalized.contains(";")) {
|
if (normalized.contains(";")) {
|
||||||
throw new AppException("Select AI 결과에 여러 SQL 문장이 포함되어 있어 반환하지 않습니다.");
|
throw new AppException("Select AI 결과에 여러 SQL 문장이 포함되어 있어 반환하지 않습니다.");
|
||||||
}
|
}
|
||||||
|
if (normalized.contains("--") || normalized.contains("/*") || normalized.contains("*/")
|
||||||
|
|| UNSAFE_SQL.matcher(normalized).find()) {
|
||||||
|
throw new AppException("Select AI 결과에 실행이 허용되지 않는 SQL 구문이 포함되어 있습니다.");
|
||||||
|
}
|
||||||
return normalized;
|
return normalized;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private record QueryExecution(ArrayNode items, boolean truncated) {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,9 +47,13 @@ class McpSseServiceTest {
|
|||||||
assertThat(agentService.prompt).isEqualTo("카제나 AU를 조회해 줘");
|
assertThat(agentService.prompt).isEqualTo("카제나 AU를 조회해 줘");
|
||||||
assertThat(response.path("error").isMissingNode()).isTrue();
|
assertThat(response.path("error").isMissingNode()).isTrue();
|
||||||
assertThat(response.path("result").path("isError").asBoolean()).isFalse();
|
assertThat(response.path("result").path("isError").asBoolean()).isFalse();
|
||||||
assertThat(response.path("result").path("content").get(0).path("text").asText())
|
String payload = response.path("result").path("content").get(0).path("text").asText();
|
||||||
|
assertThat(payload)
|
||||||
.contains("SGMP_POC_HAIKU45")
|
.contains("SGMP_POC_HAIKU45")
|
||||||
.contains("SELECT 1 FROM DUAL");
|
.contains("SELECT 1 FROM DUAL")
|
||||||
|
.contains("SHOWSQL_AND_EXECUTED")
|
||||||
|
.contains("READ_ONLY_EXECUTED")
|
||||||
|
.contains("RESULT");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -86,12 +90,17 @@ class McpSseServiceTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public JsonNode generateShowSql(String bearerToken, String prompt) {
|
public JsonNode generateAndExecute(String bearerToken, String prompt) {
|
||||||
this.bearerToken = bearerToken;
|
this.bearerToken = bearerToken;
|
||||||
this.prompt = prompt;
|
this.prompt = prompt;
|
||||||
return new ObjectMapper().createObjectNode()
|
ObjectNode response = new ObjectMapper().createObjectNode()
|
||||||
.put("profile", "SGMP_POC_HAIKU45")
|
.put("profile", "SGMP_POC_HAIKU45")
|
||||||
.put("generatedSql", "SELECT 1 FROM DUAL");
|
.put("generatedSql", "SELECT 1 FROM DUAL")
|
||||||
|
.put("status", "SHOWSQL_AND_EXECUTED")
|
||||||
|
.put("execution", "READ_ONLY_EXECUTED")
|
||||||
|
.put("rowCount", 1);
|
||||||
|
response.putArray("items").addObject().put("RESULT", 1);
|
||||||
|
return response;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user