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

@@ -69,6 +69,16 @@ export BACKOFFICE_MCP_TOOL_NAME="oracle.select_ai.data_text2sql"
export BACKOFFICE_MCP_TOOL_LABEL="업무 데이터 Text2SQL" export BACKOFFICE_MCP_TOOL_LABEL="업무 데이터 Text2SQL"
export BACKOFFICE_MCP_TOOL_DESCRIPTION="승인된 업무 데이터에 대해 읽기 전용 SQL을 생성하고 실행합니다." export BACKOFFICE_MCP_TOOL_DESCRIPTION="승인된 업무 데이터에 대해 읽기 전용 SQL을 생성하고 실행합니다."
export BACKOFFICE_MCP_PROMPT_DESCRIPTION="업무 데이터에서 조회할 내용을 자연어로 입력합니다." export BACKOFFICE_MCP_PROMPT_DESCRIPTION="업무 데이터에서 조회할 내용을 자연어로 입력합니다."
export BACKOFFICE_MCP_SHOWPROMPT_TOOL_NAME="oracle.select_ai.data_showprompt"
export BACKOFFICE_MCP_SHOWPROMPT_TOOL_LABEL="업무 데이터 SHOWPROMPT"
export BACKOFFICE_MCP_SHOWPROMPT_TOOL_DESCRIPTION="Select AI가 SQL 생성에 사용한 prompt를 조회하는 읽기 전용 진단 도구입니다."
# Select AI few-shot 예제 SQL 조회·저장 MCP. 운영 환경은 고객별 도구명과 안내문만 변경합니다.
export BACKOFFICE_MCP_QA_VECTOR_SEARCH_TOOL_NAME="oracle.select_ai.qa_vector_search"
export BACKOFFICE_MCP_QA_VECTOR_SEARCH_TOOL_LABEL="Select AI 예제 SQL 조회"
export BACKOFFICE_MCP_QA_VECTOR_SEARCH_TOOL_DESCRIPTION="현재 질문에 사용할 유사 예제 SQL을 Select AI 실행 전에 조회합니다."
export BACKOFFICE_MCP_QA_VECTOR_STORE_TOOL_NAME="oracle.select_ai.qa_vector_store"
export BACKOFFICE_MCP_QA_VECTOR_STORE_TOOL_LABEL="Select AI 예제 SQL 저장"
export BACKOFFICE_MCP_QA_VECTOR_STORE_TOOL_DESCRIPTION="검토된 Select AI 결과를 후속 Text2SQL 품질 향상용 예제 SQL로 저장합니다."
# 마스킹 관리 대상. objectName/policyName JSON 배열이며, 비우면 어떤 DB 정책도 관리하지 않습니다. # 마스킹 관리 대상. objectName/policyName JSON 배열이며, 비우면 어떤 DB 정책도 관리하지 않습니다.
export BACKOFFICE_MASKING_POLICIES='' export BACKOFFICE_MASKING_POLICIES=''
# 보안 SQL 화면에 노출할 번들 SQL. fileName은 패키지의 sql/adb/ 아래 파일명만 허용됩니다. # 보안 SQL 화면에 노출할 번들 SQL. fileName은 패키지의 sql/adb/ 아래 파일명만 허용됩니다.

View File

@@ -0,0 +1,50 @@
# 731. SGMP QA Vector MCP 도구
## 목표
백오피스 MCP에 저장된 QA 벡터 예제를 조회·저장하는 두 도구를 추가한다.
- 조회 도구는 현재 질문과 유사한 예제 SQL을 Select AI 호출 전에 확인하여
few-shot 컨텍스트로 사용할 수 있게 한다.
- 저장 도구는 검토된 Select AI 결과를 다음 질의 품질 개선용 예제 SQL로 저장한다.
## MCP 계약
| 도구 | 입력 | 반환 | 용도 |
|---|---|---|---|
| `oracle.select_ai.qa_vector_search` | `question`, 선택 `topK`(기본 3) | 예제 ID, 질문, 답 SQL, 답변, cosine distance | Select AI 실행 전 few-shot 후보 확인 |
| `oracle.select_ai.qa_vector_store` | `question`, `answerSql`, 선택 `answer` | 저장된 exampleId, 모델 | 검토된 Select AI 예제 SQL 축적 |
도구 이름·표시명·설명은 모두 `BACKOFFICE_MCP_QA_VECTOR_*` 환경 변수로
바꿀 수 있다. MCP의 공통 `prompt` 인자를 재사용하지 않아 검색과 저장의
입력 의미를 명확히 분리한다.
## 연결 및 보안
1. HTTP Bearer Token은 기존 업무 사용자 토큰 검증을 통과해야 한다.
2. 벡터 DB 호출은 `BACKOFFICE_SELECT_AI_DB_*`로 만든 SGMP_POC 연결만 사용한다.
3. API 서명 키, DB 비밀번호, credential 이름은 MCP 응답·로그에 포함하지 않는다.
4. 검색은 `SG_QA_VECTOR_SEARCH` DB 함수만 호출한다. 저장은
`SG_QA_VECTOR_STORE` DB 함수만 호출한다.
5. 저장 도구는 호출자가 검토한 결과만 보내는 운영 계약이다. Select AI 실행
결과를 자동으로 저장하지 않는다.
## Select AI 연계 순서
1. Agent가 사용자 질문으로 `qa_vector_search`를 호출한다.
2. 반환된 상위 2~3개 예제의 질문·답 SQL을 Select AI 프롬프트의 few-shot
컨텍스트로 사용한다.
3. 기존 Text2SQL 도구로 SQL을 생성·검토·실행한다.
4. 검토 통과한 질문·생성 SQL·필요 시 답변을 `qa_vector_store`로 저장한다.
현재 MCP는 검색 결과를 반환한다. Text2SQL tool의 내부 Select AI prompt에
자동 주입하는 변경은 별도 단계로 두어, 검색 결과와 실제 prompt 구성을
운영자가 먼저 확인할 수 있게 한다.
## 검증
- `tools/list`에 기존 두 도구와 새 두 도구가 함께 노출된다.
- 검색의 `topK` 기본값은 3이고 범위는 1~20이다.
- 저장 도구는 question·answerSql 없이는 호출되지 않는다.
- Bearer Token 누락 시 네 도구 모두 기존과 같은 권한 거절 응답을 반환한다.
- 서비스 단위 테스트는 DB 대신 캡처 구현으로 MCP 입력·응답 계약을 검증한다.

View File

@@ -11,7 +11,13 @@ public record McpProperties(
String promptDescription, String promptDescription,
String showpromptToolName, String showpromptToolName,
String showpromptToolLabel, String showpromptToolLabel,
String showpromptToolDescription String showpromptToolDescription,
String qaVectorSearchToolName,
String qaVectorSearchToolLabel,
String qaVectorSearchToolDescription,
String qaVectorStoreToolName,
String qaVectorStoreToolLabel,
String qaVectorStoreToolDescription
) { ) {
private static final String DEFAULT_TOOL_NAME = "oracle.select_ai.data_text2sql"; private static final String DEFAULT_TOOL_NAME = "oracle.select_ai.data_text2sql";
@@ -28,6 +34,18 @@ public record McpProperties(
private static final String DEFAULT_SHOWPROMPT_TOOL_DESCRIPTION = private static final String DEFAULT_SHOWPROMPT_TOOL_DESCRIPTION =
"Select AI가 SQL 생성에 사용한 prompt를 조회하는 읽기 전용 진단 도구입니다. " "Select AI가 SQL 생성에 사용한 prompt를 조회하는 읽기 전용 진단 도구입니다. "
+ "생성 SQL이나 데이터 조회 SQL은 실행하지 않습니다."; + "생성 SQL이나 데이터 조회 SQL은 실행하지 않습니다.";
private static final String DEFAULT_QA_VECTOR_SEARCH_TOOL_NAME =
"oracle.select_ai.qa_vector_search";
private static final String DEFAULT_QA_VECTOR_SEARCH_TOOL_LABEL = "Select AI 예제 SQL 조회";
private static final String DEFAULT_QA_VECTOR_SEARCH_TOOL_DESCRIPTION =
"현재 질문에 사용할 유사 예제 SQL을 Select AI 실행 전에 조회합니다. "
+ "반환값은 few-shot 컨텍스트 검토용이며 SQL을 실행하지 않습니다.";
private static final String DEFAULT_QA_VECTOR_STORE_TOOL_NAME =
"oracle.select_ai.qa_vector_store";
private static final String DEFAULT_QA_VECTOR_STORE_TOOL_LABEL = "Select AI 예제 SQL 저장";
private static final String DEFAULT_QA_VECTOR_STORE_TOOL_DESCRIPTION =
"검토된 Select AI 결과를 후속 Text2SQL 품질 향상용 예제 SQL로 저장합니다. "
+ "질문과 읽기 전용 답 SQL이 필요합니다.";
public String resolvedToolName() { public String resolvedToolName() {
return requiredOrDefault(toolName, DEFAULT_TOOL_NAME); return requiredOrDefault(toolName, DEFAULT_TOOL_NAME);
@@ -57,6 +75,30 @@ public record McpProperties(
return requiredOrDefault(showpromptToolDescription, DEFAULT_SHOWPROMPT_TOOL_DESCRIPTION); return requiredOrDefault(showpromptToolDescription, DEFAULT_SHOWPROMPT_TOOL_DESCRIPTION);
} }
public String resolvedQaVectorSearchToolName() {
return requiredOrDefault(qaVectorSearchToolName, DEFAULT_QA_VECTOR_SEARCH_TOOL_NAME);
}
public String resolvedQaVectorSearchToolLabel() {
return requiredOrDefault(qaVectorSearchToolLabel, DEFAULT_QA_VECTOR_SEARCH_TOOL_LABEL);
}
public String resolvedQaVectorSearchToolDescription() {
return requiredOrDefault(qaVectorSearchToolDescription, DEFAULT_QA_VECTOR_SEARCH_TOOL_DESCRIPTION);
}
public String resolvedQaVectorStoreToolName() {
return requiredOrDefault(qaVectorStoreToolName, DEFAULT_QA_VECTOR_STORE_TOOL_NAME);
}
public String resolvedQaVectorStoreToolLabel() {
return requiredOrDefault(qaVectorStoreToolLabel, DEFAULT_QA_VECTOR_STORE_TOOL_LABEL);
}
public String resolvedQaVectorStoreToolDescription() {
return requiredOrDefault(qaVectorStoreToolDescription, DEFAULT_QA_VECTOR_STORE_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

@@ -20,17 +20,20 @@ public class McpSseService {
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
private final BackofficeProperties properties; private final BackofficeProperties properties;
private final McpProperties mcpProperties; private final McpProperties mcpProperties;
private final QaVectorService qaVectorService;
public McpSseService( public McpSseService(
SelectAiService selectAiService, SelectAiService selectAiService,
ObjectMapper objectMapper, ObjectMapper objectMapper,
BackofficeProperties properties, BackofficeProperties properties,
McpProperties mcpProperties McpProperties mcpProperties,
QaVectorService qaVectorService
) { ) {
this.selectAiService = selectAiService; this.selectAiService = selectAiService;
this.objectMapper = objectMapper; this.objectMapper = objectMapper;
this.properties = properties; this.properties = properties;
this.mcpProperties = mcpProperties; this.mcpProperties = mcpProperties;
this.qaVectorService = qaVectorService;
} }
public ObjectNode handle(String contextPath, JsonNode request) { public ObjectNode handle(String contextPath, JsonNode request) {
@@ -69,7 +72,8 @@ public class McpSseService {
/** Tools registered by this MCP server. */ /** Tools registered by this MCP server. */
public List<McpToolView> registeredTools() { public List<McpToolView> registeredTools() {
return List.of(selectAiQueryView(), selectAiShowpromptView()); return List.of(
selectAiQueryView(), selectAiShowpromptView(), qaVectorSearchView(), qaVectorStoreView());
} }
private ObjectNode initializeResult(String contextPath) { private ObjectNode initializeResult(String contextPath) {
@@ -90,6 +94,8 @@ public class McpSseService {
ArrayNode tools = objectMapper.createArrayNode(); ArrayNode tools = objectMapper.createArrayNode();
tools.add(toolDefinition(selectAiQueryView())); tools.add(toolDefinition(selectAiQueryView()));
tools.add(toolDefinition(selectAiShowpromptView())); tools.add(toolDefinition(selectAiShowpromptView()));
tools.add(toolDefinition(qaVectorSearchView()));
tools.add(toolDefinition(qaVectorStoreView()));
result.set("tools", tools); result.set("tools", tools);
return result; return result;
} }
@@ -103,26 +109,47 @@ public class McpSseService {
schema.put("type", "object"); schema.put("type", "object");
ObjectNode properties = objectMapper.createObjectNode(); 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(); 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.set("required", required);
schema.put("additionalProperties", false); schema.put("additionalProperties", false);
item.set("inputSchema", schema); item.set("inputSchema", schema);
return item; 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) { private ObjectNode toolsCallResult(JsonNode params, String vpdBearerToken) {
String calledToolName = params.path("name").asText(""); String calledToolName = params.path("name").asText("");
boolean queryTool = toolName().equals(calledToolName); boolean queryTool = toolName().equals(calledToolName);
boolean showpromptTool = showpromptToolName().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); throw new AppException("등록되지 않은 MCP tool입니다: " + calledToolName);
} }
@@ -133,10 +160,22 @@ public class McpSseService {
} }
JsonNode response; JsonNode response;
try { try {
String prompt = arguments.path("prompt").asText(""); if (qaVectorSearchTool) {
response = queryTool response = qaVectorSearchResponse(
? selectAiService.generateAndExecute(token, prompt) qaVectorService.search(token, arguments.path("question").asText(""), arguments.path("topK").asInt(3)));
: selectAiService.generatePrompt(token, prompt); } 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) { } catch (VpdTokenAccessDeniedException ignored) {
return tokenAccessDeniedResult(); return tokenAccessDeniedResult();
} }
@@ -158,6 +197,37 @@ public class McpSseService {
return result; 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() { private ObjectNode tokenAccessDeniedResult() {
ObjectNode payload = objectMapper.createObjectNode(); ObjectNode payload = objectMapper.createObjectNode();
payload.put("status", "VPD_TOKEN_DENIED"); 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() { private String selectAiProfile() {
BackofficeProperties.SelectAi selectAi = properties == null ? null : properties.selectAi(); BackofficeProperties.SelectAi selectAi = properties == null ? null : properties.selectAi();
if (selectAi == null || selectAi.profile() == null || selectAi.profile().isBlank()) { if (selectAi == null || selectAi.profile() == null || selectAi.profile().isBlank()) {
@@ -240,6 +322,38 @@ public class McpSseService {
: mcpProperties.resolvedShowpromptToolDescription(); : 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) { private String pretty(Object value) {
try { try {
return objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(value); 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) {
}
}

View File

@@ -90,6 +90,12 @@ backoffice:
showprompt-tool-name: ${BACKOFFICE_MCP_SHOWPROMPT_TOOL_NAME:oracle.select_ai.data_showprompt} showprompt-tool-name: ${BACKOFFICE_MCP_SHOWPROMPT_TOOL_NAME:oracle.select_ai.data_showprompt}
showprompt-tool-label: ${BACKOFFICE_MCP_SHOWPROMPT_TOOL_LABEL:업무 데이터 SHOWPROMPT} showprompt-tool-label: ${BACKOFFICE_MCP_SHOWPROMPT_TOOL_LABEL:업무 데이터 SHOWPROMPT}
showprompt-tool-description: ${BACKOFFICE_MCP_SHOWPROMPT_TOOL_DESCRIPTION:Select AI가 SQL 생성에 사용한 prompt를 조회하는 읽기 전용 진단 도구입니다. 생성 SQL이나 데이터 조회 SQL은 실행하지 않습니다.} showprompt-tool-description: ${BACKOFFICE_MCP_SHOWPROMPT_TOOL_DESCRIPTION:Select AI가 SQL 생성에 사용한 prompt를 조회하는 읽기 전용 진단 도구입니다. 생성 SQL이나 데이터 조회 SQL은 실행하지 않습니다.}
qa-vector-search-tool-name: ${BACKOFFICE_MCP_QA_VECTOR_SEARCH_TOOL_NAME:oracle.select_ai.qa_vector_search}
qa-vector-search-tool-label: ${BACKOFFICE_MCP_QA_VECTOR_SEARCH_TOOL_LABEL:Select AI 예제 SQL 조회}
qa-vector-search-tool-description: ${BACKOFFICE_MCP_QA_VECTOR_SEARCH_TOOL_DESCRIPTION:현재 질문에 사용할 유사 예제 SQL을 Select AI 실행 전에 조회합니다. 반환값은 few-shot 컨텍스트 검토용이며 SQL을 실행하지 않습니다.}
qa-vector-store-tool-name: ${BACKOFFICE_MCP_QA_VECTOR_STORE_TOOL_NAME:oracle.select_ai.qa_vector_store}
qa-vector-store-tool-label: ${BACKOFFICE_MCP_QA_VECTOR_STORE_TOOL_LABEL:Select AI 예제 SQL 저장}
qa-vector-store-tool-description: ${BACKOFFICE_MCP_QA_VECTOR_STORE_TOOL_DESCRIPTION:검토된 Select AI 결과를 후속 Text2SQL 품질 향상용 예제 SQL로 저장합니다. 질문과 읽기 전용 답 SQL이 필요합니다.}
masking: masking:
policies: ${BACKOFFICE_MASKING_POLICIES:} policies: ${BACKOFFICE_MASKING_POLICIES:}
security-sql-scripts: security-sql-scripts:

View File

@@ -8,7 +8,7 @@
<h1>MCP 연동</h1> <h1>MCP 연동</h1>
<details class="explanation-details"> <details class="explanation-details">
<summary>도움말</summary> <summary>도움말</summary>
<p>사용자 Bearer Token을 검증한 뒤 구성된 Select AI 프로파일로 업무 데이터 Text2SQL을 생성하는 MCP tool을 제공합니다. Select AI는 comment, annotation, constraint 메타데이터를 함께 사용하며, 생성 SQL은 자동 실행하지 않습니다.</p> <p>사용자 Bearer Token을 검증한 뒤 구성된 Select AI 프로파일 Text2SQL·SHOWPROMPT와 QA 벡터 예제 SQL 조회·저장 MCP tool을 제공합니다. QA 조회는 SQL 생성 전에 few-shot 후보를 확인하는 용도이며, 저장은 검토된 예제를 축적하는 용도입니다.</p>
</details> </details>
</section> </section>
@@ -99,7 +99,7 @@
<td> <td>
<div th:text="${tool.description()}">ORDS 행 접근 조회 도구 설명</div> <div th:text="${tool.description()}">ORDS 행 접근 조회 도구 설명</div>
<small class="text-muted"> <small class="text-muted">
HTTP <code>Authorization</code> → 활성 사용자 토큰 검증 · <code>prompt</code> → 업무 데이터 Text2SQL 생성 · 결과 SQL 검토 후 별도 실행 HTTP <code>Authorization</code> → 활성 사용자 토큰 검증 · 도구별 <code>prompt</code> 또는 <code>question</code> 입력 · QA 조회 결과는 few-shot 예제 SQL 검토
</small> </small>
</td> </td>
</tr> </tr>
@@ -115,10 +115,19 @@
<details class="explanation-details"> <details class="explanation-details">
<summary>tools/call parameter 예시 보기</summary> <summary>tools/call parameter 예시 보기</summary>
<h2>tools/call Arguments</h2> <h2>tools/call Arguments</h2>
<pre class="code-block">{ <pre class="code-block">// qa_vector_search
"prompt": "최신 기준 활성 사용자 수를 조회하는 SQL을 만들어줘." {
"question": "최신 기준 활성 사용자 수를 조회해줘.",
"topK": 3
}
// qa_vector_store
{
"question": "최신 기준 활성 사용자 수를 조회해줘.",
"answerSql": "SELECT COUNT(*) AS AU_COUNT FROM ...",
"answer": "AU_COUNT=123"
}</pre> }</pre>
<p class="form-hint">등록 tool은 <code th:text="${mcp?.resolvedToolName() ?: 'oracle.select_ai.data_text2sql'}">oracle.select_ai.data_text2sql</code> 하나입니다. 활성 사용자 Bearer Token을 확인한 뒤 Select AI가 comment, annotation, constraint를 참고해 읽기 전용 업무 데이터 SQL을 생성합니다. 실행은 자동으로 수행하지 않습니다.</p> <p class="form-hint">Text2SQL·SHOWPROMPT는 <code>prompt</code>를 사용하고, <code th:text="${mcp?.resolvedQaVectorSearchToolName() ?: 'oracle.select_ai.qa_vector_search'}">oracle.select_ai.qa_vector_search</code>는 few-shot 후보를 조회하며 <code th:text="${mcp?.resolvedQaVectorStoreToolName() ?: 'oracle.select_ai.qa_vector_store'}">oracle.select_ai.qa_vector_store</code>는 검토된 읽기 전용 예제 SQL을 저장합니다.</p>
</details> </details>
</section> </section>

View File

@@ -12,6 +12,7 @@ class McpSseServiceTest {
private final ObjectMapper objectMapper = new ObjectMapper(); private final ObjectMapper objectMapper = new ObjectMapper();
private final SelectAiService selectAiService = new CapturingSelectAiService(); private final SelectAiService selectAiService = new CapturingSelectAiService();
private final QaVectorService qaVectorService = new CapturingQaVectorService();
private final McpSseService service = new McpSseService( private final McpSseService service = new McpSseService(
selectAiService, selectAiService,
objectMapper, objectMapper,
@@ -29,16 +30,23 @@ class McpSseServiceTest {
"테스트 데이터의 조회 내용을 입력합니다.", "테스트 데이터의 조회 내용을 입력합니다.",
"oracle.select_ai.test_data_showprompt", "oracle.select_ai.test_data_showprompt",
"테스트 데이터 SHOWPROMPT", "테스트 데이터 SHOWPROMPT",
"테스트 데이터의 Select AI prompt를 조회합니다." "테스트 데이터의 Select AI prompt를 조회합니다.",
) "oracle.select_ai.test_qa_vector_search",
"테스트 예제 SQL 조회",
"테스트 질문에 사용할 예제 SQL을 조회합니다.",
"oracle.select_ai.test_qa_vector_store",
"테스트 예제 SQL 저장",
"테스트 예제 SQL을 저장합니다."
),
qaVectorService
); );
@Test @Test
void listsQueryAndShowpromptToolsWithPromptInput() { void listsQueryShowpromptAndQaVectorToolsWithTheirInputs() {
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(2); assertThat(tools).hasSize(4);
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");
@@ -58,6 +66,21 @@ class McpSseServiceTest {
assertThat(showprompt.path("inputSchema").path("required")) assertThat(showprompt.path("inputSchema").path("required"))
.extracting(node -> node.asText()) .extracting(node -> node.asText())
.contains("prompt"); .contains("prompt");
var vectorSearch = tools.get(2);
assertThat(vectorSearch.path("name").asText())
.isEqualTo("oracle.select_ai.test_qa_vector_search");
assertThat(vectorSearch.path("inputSchema").path("properties").path("question").path("type").asText())
.isEqualTo("string");
assertThat(vectorSearch.path("inputSchema").path("properties").path("topK").path("default").asInt())
.isEqualTo(3);
var vectorStore = tools.get(3);
assertThat(vectorStore.path("name").asText())
.isEqualTo("oracle.select_ai.test_qa_vector_store");
assertThat(vectorStore.path("inputSchema").path("required"))
.extracting(node -> node.asText())
.contains("question", "answerSql");
} }
@Test @Test
@@ -120,6 +143,44 @@ class McpSseServiceTest {
.contains("권한이 없습니다"); .contains("권한이 없습니다");
} }
@Test
void searchesQaVectorExamplesForFewShotContext() {
ObjectNode request = request(5, "tools/call");
ObjectNode params = (ObjectNode) request.putObject("params");
params.put("name", "oracle.select_ai.test_qa_vector_search");
params.putObject("arguments").put("question", "active users by game").put("topK", 2);
ObjectNode response = service.handle("default", request, "user-bearer");
CapturingQaVectorService vectorService = (CapturingQaVectorService) qaVectorService;
assertThat(vectorService.bearerToken).isEqualTo("user-bearer");
assertThat(vectorService.question).isEqualTo("active users by game");
assertThat(vectorService.topK).isEqualTo(2);
assertThat(response.path("result").path("content").get(0).path("text").asText())
.contains("QA_VECTOR_SEARCH")
.contains("few-shot")
.contains("SELECT COUNT(*) FROM APP_USER");
}
@Test
void storesReviewedSelectAiExampleSql() {
ObjectNode request = request(6, "tools/call");
ObjectNode params = (ObjectNode) request.putObject("params");
params.put("name", "oracle.select_ai.test_qa_vector_store");
params.putObject("arguments")
.put("question", "active users by game")
.put("answerSql", "SELECT COUNT(*) FROM APP_USER")
.put("answer", "AU count");
ObjectNode response = service.handle("default", request, "user-bearer");
CapturingQaVectorService vectorService = (CapturingQaVectorService) qaVectorService;
assertThat(vectorService.answerSql).isEqualTo("SELECT COUNT(*) FROM APP_USER");
assertThat(response.path("result").path("content").get(0).path("text").asText())
.contains("QA_VECTOR_STORED")
.contains("exampleId");
}
private ObjectNode request(int id, String method) { private ObjectNode request(int id, String method) {
ObjectNode request = objectMapper.createObjectNode(); ObjectNode request = objectMapper.createObjectNode();
request.put("jsonrpc", "2.0"); request.put("jsonrpc", "2.0");
@@ -163,4 +224,35 @@ class McpSseServiceTest {
.put("selectAiPrompt", "assembled Select AI prompt"); .put("selectAiPrompt", "assembled Select AI prompt");
} }
} }
private static final class CapturingQaVectorService extends QaVectorService {
private String bearerToken;
private String question;
private String answerSql;
private int topK;
private CapturingQaVectorService() {
super(null, null, null);
}
@Override
public VectorSearchResult search(String bearerToken, String question, int topK) {
this.bearerToken = bearerToken;
this.question = question;
this.topK = topK;
return new VectorSearchResult(question, topK, java.util.List.of(new VectorExample(
42L, "active user count", "SELECT COUNT(*) FROM APP_USER", "AU count",
"cohere.embed-v4.0", 0.12
)));
}
@Override
public VectorStoreResult store(String bearerToken, String question, String answerSql, String answer) {
this.bearerToken = bearerToken;
this.question = question;
this.answerSql = answerSql;
return new VectorStoreResult(77L, question, "cohere.embed-v4.0");
}
}
} }