refs #731: add QA vector MCP tools

This commit is contained in:
devmrko
2026-07-24 14:41:00 +09:00
parent 744dcad634
commit df19912378
8 changed files with 522 additions and 25 deletions

View File

@@ -20,17 +20,20 @@ public class McpSseService {
private final ObjectMapper objectMapper;
private final BackofficeProperties properties;
private final McpProperties mcpProperties;
private final QaVectorService qaVectorService;
public McpSseService(
SelectAiService selectAiService,
ObjectMapper objectMapper,
BackofficeProperties properties,
McpProperties mcpProperties
McpProperties mcpProperties,
QaVectorService qaVectorService
) {
this.selectAiService = selectAiService;
this.objectMapper = objectMapper;
this.properties = properties;
this.mcpProperties = mcpProperties;
this.qaVectorService = qaVectorService;
}
public ObjectNode handle(String contextPath, JsonNode request) {
@@ -69,7 +72,8 @@ public class McpSseService {
/** Tools registered by this MCP server. */
public List<McpToolView> registeredTools() {
return List.of(selectAiQueryView(), selectAiShowpromptView());
return List.of(
selectAiQueryView(), selectAiShowpromptView(), qaVectorSearchView(), qaVectorStoreView());
}
private ObjectNode initializeResult(String contextPath) {
@@ -90,6 +94,8 @@ public class McpSseService {
ArrayNode tools = objectMapper.createArrayNode();
tools.add(toolDefinition(selectAiQueryView()));
tools.add(toolDefinition(selectAiShowpromptView()));
tools.add(toolDefinition(qaVectorSearchView()));
tools.add(toolDefinition(qaVectorStoreView()));
result.set("tools", tools);
return result;
}
@@ -103,26 +109,47 @@ public class McpSseService {
schema.put("type", "object");
ObjectNode properties = objectMapper.createObjectNode();
ObjectNode prompt = objectMapper.createObjectNode();
prompt.put("type", "string");
prompt.put("description", promptDescription());
prompt.put("maxLength", 4000);
properties.set("prompt", prompt);
schema.set("properties", properties);
ArrayNode required = objectMapper.createArrayNode();
required.add("prompt");
if (qaVectorSearchToolName().equals(toolView.name())) {
addStringProperty(properties, "question", "few-shot 예제 SQL을 찾을 현재 질문입니다.", 4000);
ObjectNode topK = properties.putObject("topK");
topK.put("type", "integer");
topK.put("description", "반환할 유사 예제 수입니다. 기본값은 3입니다.");
topK.put("minimum", 1);
topK.put("maximum", 20);
topK.put("default", 3);
required.add("question");
} else if (qaVectorStoreToolName().equals(toolView.name())) {
addStringProperty(properties, "question", "검토된 Select AI 예제가 답한 업무 질문입니다.", 4000);
addStringProperty(properties, "answerSql", "검토된 단일 읽기 전용 SELECT/WITH SQL입니다.", 20000);
addStringProperty(properties, "answer", "선택 사항인 답변 또는 검토 메모입니다.", 20000);
required.add("question");
required.add("answerSql");
} else {
addStringProperty(properties, "prompt", promptDescription(), 4000);
required.add("prompt");
}
schema.set("properties", properties);
schema.set("required", required);
schema.put("additionalProperties", false);
item.set("inputSchema", schema);
return item;
}
private void addStringProperty(ObjectNode properties, String name, String description, int maxLength) {
ObjectNode property = properties.putObject(name);
property.put("type", "string");
property.put("description", description);
property.put("maxLength", maxLength);
}
private ObjectNode toolsCallResult(JsonNode params, String vpdBearerToken) {
String calledToolName = params.path("name").asText("");
boolean queryTool = toolName().equals(calledToolName);
boolean showpromptTool = showpromptToolName().equals(calledToolName);
if (!queryTool && !showpromptTool) {
boolean qaVectorSearchTool = qaVectorSearchToolName().equals(calledToolName);
boolean qaVectorStoreTool = qaVectorStoreToolName().equals(calledToolName);
if (!queryTool && !showpromptTool && !qaVectorSearchTool && !qaVectorStoreTool) {
throw new AppException("등록되지 않은 MCP tool입니다: " + calledToolName);
}
@@ -133,10 +160,22 @@ public class McpSseService {
}
JsonNode response;
try {
String prompt = arguments.path("prompt").asText("");
response = queryTool
? selectAiService.generateAndExecute(token, prompt)
: selectAiService.generatePrompt(token, prompt);
if (qaVectorSearchTool) {
response = qaVectorSearchResponse(
qaVectorService.search(token, arguments.path("question").asText(""), arguments.path("topK").asInt(3)));
} else if (qaVectorStoreTool) {
response = qaVectorStoreResponse(qaVectorService.store(
token,
arguments.path("question").asText(""),
arguments.path("answerSql").asText(""),
arguments.path("answer").isMissingNode() ? null : arguments.path("answer").asText(null)
));
} else {
String prompt = arguments.path("prompt").asText("");
response = queryTool
? selectAiService.generateAndExecute(token, prompt)
: selectAiService.generatePrompt(token, prompt);
}
} catch (VpdTokenAccessDeniedException ignored) {
return tokenAccessDeniedResult();
}
@@ -158,6 +197,37 @@ public class McpSseService {
return result;
}
private ObjectNode qaVectorSearchResponse(QaVectorService.VectorSearchResult result) {
ObjectNode response = objectMapper.createObjectNode();
response.put("status", "QA_VECTOR_SEARCH");
response.put("instruction", "Select AI SQL 생성 전에 few-shot 예제 SQL 후보를 확인합니다.");
response.put("question", result.question());
response.put("topK", result.topK());
ArrayNode examples = response.putArray("examples");
for (QaVectorService.VectorExample example : result.examples()) {
ObjectNode item = examples.addObject();
item.put("exampleId", example.exampleId());
item.put("question", example.question());
item.put("answerSql", example.answerSql());
if (example.answer() != null) {
item.put("answer", example.answer());
}
item.put("embeddingModel", example.embeddingModel());
item.put("cosineDistance", example.cosineDistance());
}
return response;
}
private ObjectNode qaVectorStoreResponse(QaVectorService.VectorStoreResult stored) {
ObjectNode response = objectMapper.createObjectNode();
response.put("status", "QA_VECTOR_STORED");
response.put("instruction", "검토된 Select AI 결과를 후속 Text2SQL 품질 향상용 예제 SQL로 저장했습니다.");
response.put("exampleId", stored.exampleId());
response.put("question", stored.question());
response.put("embeddingModel", stored.embeddingModel());
return response;
}
private ObjectNode tokenAccessDeniedResult() {
ObjectNode payload = objectMapper.createObjectNode();
payload.put("status", "VPD_TOKEN_DENIED");
@@ -196,6 +266,18 @@ public class McpSseService {
);
}
private McpToolView qaVectorSearchView() {
return new McpToolView(
qaVectorSearchToolName(), qaVectorSearchToolDescription(), -1L,
qaVectorSearchToolLabel(), SELECT_AI_TOOL_PATH);
}
private McpToolView qaVectorStoreView() {
return new McpToolView(
qaVectorStoreToolName(), qaVectorStoreToolDescription(), -1L,
qaVectorStoreToolLabel(), SELECT_AI_TOOL_PATH);
}
private String selectAiProfile() {
BackofficeProperties.SelectAi selectAi = properties == null ? null : properties.selectAi();
if (selectAi == null || selectAi.profile() == null || selectAi.profile().isBlank()) {
@@ -240,6 +322,38 @@ public class McpSseService {
: mcpProperties.resolvedShowpromptToolDescription();
}
private String qaVectorSearchToolName() {
return mcpProperties == null ? "oracle.select_ai.qa_vector_search"
: mcpProperties.resolvedQaVectorSearchToolName();
}
private String qaVectorSearchToolLabel() {
return mcpProperties == null ? "Select AI 예제 SQL 조회"
: mcpProperties.resolvedQaVectorSearchToolLabel();
}
private String qaVectorSearchToolDescription() {
return mcpProperties == null
? "현재 질문에 사용할 유사 예제 SQL을 Select AI 실행 전에 조회합니다."
: mcpProperties.resolvedQaVectorSearchToolDescription();
}
private String qaVectorStoreToolName() {
return mcpProperties == null ? "oracle.select_ai.qa_vector_store"
: mcpProperties.resolvedQaVectorStoreToolName();
}
private String qaVectorStoreToolLabel() {
return mcpProperties == null ? "Select AI 예제 SQL 저장"
: mcpProperties.resolvedQaVectorStoreToolLabel();
}
private String qaVectorStoreToolDescription() {
return mcpProperties == null
? "검토된 Select AI 결과를 후속 Text2SQL 품질 향상용 예제 SQL로 저장합니다."
: mcpProperties.resolvedQaVectorStoreToolDescription();
}
private String pretty(Object value) {
try {
return objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(value);

View File

@@ -0,0 +1,174 @@
package com.cloudhandson.vpdbackoffice.service;
import com.cloudhandson.vpdbackoffice.config.BackofficeProperties;
import com.cloudhandson.vpdbackoffice.domain.token.BearerTokenRecord;
import java.sql.CallableStatement;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.Types;
import java.time.Clock;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.ArrayList;
import java.util.List;
import java.util.regex.Pattern;
import org.springframework.stereotype.Service;
/** Calls the SGMP_POC QA-vector database API through the schema-owned Select AI connection. */
@Service
public class QaVectorService {
private static final int MAX_QUESTION_LENGTH = 4_000;
private static final int MAX_ANSWER_SQL_LENGTH = 20_000;
private static final int MAX_ANSWER_LENGTH = 20_000;
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|dbms_[a-z0-9_]*|"
+ "utl_[a-z0-9_]*|sys\\s*\\.)\\b"
);
private final BackofficeProperties properties;
private final BearerTokenService bearerTokenService;
private final Clock clock;
public QaVectorService(
BackofficeProperties properties,
BearerTokenService bearerTokenService,
Clock clock
) {
this.properties = properties;
this.bearerTokenService = bearerTokenService;
this.clock = clock;
}
public VectorSearchResult search(String bearerToken, String question, int topK) {
requireActiveToken(bearerToken);
String normalizedQuestion = requiredText(question, "question", MAX_QUESTION_LENGTH);
if (topK < 1 || topK > 20) {
throw new AppException("topK는 1에서 20 사이여야 합니다.");
}
BackofficeProperties.SelectAi selectAi = requiredSelectAi();
List<VectorExample> examples = new ArrayList<>();
try (Connection connection = DriverManager.getConnection(
selectAi.dbUrl(), selectAi.dbUsername(), selectAi.dbPassword());
CallableStatement statement = connection.prepareCall("{ ? = call sg_qa_vector_search(?, ?) }")) {
statement.registerOutParameter(1, Types.REF_CURSOR);
statement.setString(2, normalizedQuestion);
statement.setInt(3, topK);
statement.execute();
try (ResultSet resultSet = (ResultSet) statement.getObject(1)) {
while (resultSet.next()) {
examples.add(new VectorExample(
resultSet.getLong("EXAMPLE_ID"),
resultSet.getString("QUESTION"),
resultSet.getString("ANSWER_SQL"),
resultSet.getString("ANSWER_TEXT"),
resultSet.getString("EMBEDDING_MODEL"),
resultSet.getDouble("COSINE_DISTANCE")
));
}
}
} catch (Exception exception) {
throw new AppException("QA 벡터 예제 SQL 조회 실패: " + exception.getMessage());
}
return new VectorSearchResult(normalizedQuestion, topK, examples);
}
public VectorStoreResult store(String bearerToken, String question, String answerSql, String answer) {
requireActiveToken(bearerToken);
String normalizedQuestion = requiredText(question, "question", MAX_QUESTION_LENGTH);
String normalizedAnswerSql = requiredReadOnlySql(answerSql);
String normalizedAnswer = optionalText(answer, "answer", MAX_ANSWER_LENGTH);
BackofficeProperties.SelectAi selectAi = requiredSelectAi();
try (Connection connection = DriverManager.getConnection(
selectAi.dbUrl(), selectAi.dbUsername(), selectAi.dbPassword());
PreparedStatement statement = connection.prepareStatement(
"SELECT sg_qa_vector_store(?, ?, ?) AS example_id FROM dual")) {
statement.setString(1, normalizedQuestion);
statement.setString(2, normalizedAnswerSql);
statement.setString(3, normalizedAnswer);
try (ResultSet resultSet = statement.executeQuery()) {
if (!resultSet.next()) {
throw new AppException("QA 벡터 예제 SQL 저장 결과가 없습니다.");
}
return new VectorStoreResult(
resultSet.getLong("EXAMPLE_ID"), normalizedQuestion, "cohere.embed-v4.0");
}
} catch (AppException exception) {
throw exception;
} catch (Exception exception) {
throw new AppException("QA 벡터 예제 SQL 저장 실패: " + exception.getMessage());
}
}
private BackofficeProperties.SelectAi requiredSelectAi() {
BackofficeProperties.SelectAi selectAi = properties == null ? null : properties.selectAi();
if (selectAi == null || !selectAi.configured()) {
throw new AppException("QA 벡터 연결 설정이 필요합니다. "
+ "BACKOFFICE_SELECT_AI_DB_URL, BACKOFFICE_SELECT_AI_DB_USERNAME, "
+ "BACKOFFICE_SELECT_AI_DB_PASSWORD를 확인하세요.");
}
return selectAi;
}
private void requireActiveToken(String bearerToken) {
if (bearerToken == null || bearerToken.isBlank()) {
throw new VpdTokenAccessDeniedException();
}
BearerTokenRecord token = bearerTokenService.findByPlainToken(bearerToken.trim());
LocalDateTime now = LocalDateTime.now(clock.withZone(ZoneId.systemDefault()));
if (token == null || !token.active(now)) {
throw new VpdTokenAccessDeniedException();
}
}
private String requiredReadOnlySql(String answerSql) {
String normalized = requiredText(answerSql, "answerSql", MAX_ANSWER_SQL_LENGTH)
.replaceFirst(";\\s*$", "").trim();
if (!normalized.matches("(?is)^(select|with)\\b.*") || normalized.contains(";")
|| normalized.contains("--") || normalized.contains("/*") || normalized.contains("*/")
|| UNSAFE_SQL.matcher(normalized).find()) {
throw new AppException("answerSql은 단일 읽기 전용 SELECT/WITH SQL이어야 합니다.");
}
return normalized;
}
private String requiredText(String value, String fieldName, int maximumLength) {
String normalized = optionalText(value, fieldName, maximumLength);
if (normalized == null) {
throw new AppException(fieldName + "는 필수입니다.");
}
return normalized;
}
private String optionalText(String value, String fieldName, int maximumLength) {
String normalized = value == null ? "" : value.trim();
if (normalized.isEmpty()) {
return null;
}
if (normalized.length() > maximumLength) {
throw new AppException(fieldName + "" + maximumLength + "자 이하여야 합니다.");
}
return normalized;
}
public record VectorExample(
long exampleId,
String question,
String answerSql,
String answer,
String embeddingModel,
double cosineDistance
) {
}
public record VectorSearchResult(String question, int topK, List<VectorExample> examples) {
}
public record VectorStoreResult(long exampleId, String question, String embeddingModel) {
}
}