diff --git a/.env.example b/.env.example index ac1188d..752cd88 100644 --- a/.env.example +++ b/.env.example @@ -69,6 +69,16 @@ export BACKOFFICE_MCP_TOOL_NAME="oracle.select_ai.data_text2sql" export BACKOFFICE_MCP_TOOL_LABEL="업무 데이터 Text2SQL" export BACKOFFICE_MCP_TOOL_DESCRIPTION="승인된 업무 데이터에 대해 읽기 전용 SQL을 생성하고 실행합니다." 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 정책도 관리하지 않습니다. export BACKOFFICE_MASKING_POLICIES='' # 보안 SQL 화면에 노출할 번들 SQL. fileName은 패키지의 sql/adb/ 아래 파일명만 허용됩니다. diff --git a/docs/design/727-sgmp-qa-vector-mcp-tools/README.md b/docs/design/727-sgmp-qa-vector-mcp-tools/README.md new file mode 100644 index 0000000..8006759 --- /dev/null +++ b/docs/design/727-sgmp-qa-vector-mcp-tools/README.md @@ -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 입력·응답 계약을 검증한다. diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/config/McpProperties.java b/src/main/java/com/cloudhandson/vpdbackoffice/config/McpProperties.java index f915711..4f0333f 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/config/McpProperties.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/config/McpProperties.java @@ -11,7 +11,13 @@ public record McpProperties( String promptDescription, String showpromptToolName, 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"; @@ -28,6 +34,18 @@ public record McpProperties( private static final String DEFAULT_SHOWPROMPT_TOOL_DESCRIPTION = "Select AI가 SQL 생성에 사용한 prompt를 조회하는 읽기 전용 진단 도구입니다. " + "생성 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() { return requiredOrDefault(toolName, DEFAULT_TOOL_NAME); @@ -57,6 +75,30 @@ public record McpProperties( 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) { return value == null || value.isBlank() ? fallback : value.trim(); } diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/McpSseService.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/McpSseService.java index 4578a69..9f8ca59 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/service/McpSseService.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/service/McpSseService.java @@ -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 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); diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/QaVectorService.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/QaVectorService.java new file mode 100644 index 0000000..73a5459 --- /dev/null +++ b/src/main/java/com/cloudhandson/vpdbackoffice/service/QaVectorService.java @@ -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 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 examples) { + } + + public record VectorStoreResult(long exampleId, String question, String embeddingModel) { + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 7ee7b44..3cd189f 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -90,6 +90,12 @@ backoffice: 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은 실행하지 않습니다.} + 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: policies: ${BACKOFFICE_MASKING_POLICIES:} security-sql-scripts: diff --git a/src/main/resources/templates/mcp-sse.html b/src/main/resources/templates/mcp-sse.html index 381dea0..8b84c59 100644 --- a/src/main/resources/templates/mcp-sse.html +++ b/src/main/resources/templates/mcp-sse.html @@ -8,7 +8,7 @@

MCP 연동

도움말 -

사용자 Bearer Token을 검증한 뒤 구성된 Select AI 프로파일로 업무 데이터 Text2SQL을 생성하는 MCP tool을 제공합니다. Select AI는 comment, annotation, constraint 메타데이터를 함께 사용하며, 생성 SQL은 자동 실행하지 않습니다.

+

사용자 Bearer Token을 검증한 뒤 구성된 Select AI 프로파일의 Text2SQL·SHOWPROMPT와 QA 벡터 예제 SQL 조회·저장 MCP tool을 제공합니다. QA 조회는 SQL 생성 전에 few-shot 후보를 확인하는 용도이며, 저장은 검토된 예제를 축적하는 용도입니다.

@@ -99,7 +99,7 @@
ORDS 행 접근 조회 도구 설명
- HTTP Authorization → 활성 사용자 토큰 검증 · prompt → 업무 데이터 Text2SQL 생성 · 결과 SQL은 검토 후 별도 실행 + HTTP Authorization → 활성 사용자 토큰 검증 · 도구별 prompt 또는 question 입력 · QA 조회 결과는 few-shot 예제 SQL 검토용 @@ -115,10 +115,19 @@
tools/call parameter 예시 보기

tools/call Arguments

-
{
-  "prompt": "최신 기준 활성 사용자 수를 조회하는 SQL을 만들어줘."
+      
// qa_vector_search
+{
+  "question": "최신 기준 활성 사용자 수를 조회해줘.",
+  "topK": 3
+}
+
+// qa_vector_store
+{
+  "question": "최신 기준 활성 사용자 수를 조회해줘.",
+  "answerSql": "SELECT COUNT(*) AS AU_COUNT FROM ...",
+  "answer": "AU_COUNT=123"
 }
-

등록 tool은 oracle.select_ai.data_text2sql 하나입니다. 활성 사용자 Bearer Token을 확인한 뒤 Select AI가 comment, annotation, constraint를 참고해 읽기 전용 업무 데이터 SQL을 생성합니다. 실행은 자동으로 수행하지 않습니다.

+

Text2SQL·SHOWPROMPT는 prompt를 사용하고, oracle.select_ai.qa_vector_search는 few-shot 후보를 조회하며 oracle.select_ai.qa_vector_store는 검토된 읽기 전용 예제 SQL을 저장합니다.

diff --git a/src/test/java/com/cloudhandson/vpdbackoffice/service/McpSseServiceTest.java b/src/test/java/com/cloudhandson/vpdbackoffice/service/McpSseServiceTest.java index 9d084e4..15c86bf 100644 --- a/src/test/java/com/cloudhandson/vpdbackoffice/service/McpSseServiceTest.java +++ b/src/test/java/com/cloudhandson/vpdbackoffice/service/McpSseServiceTest.java @@ -12,6 +12,7 @@ class McpSseServiceTest { private final ObjectMapper objectMapper = new ObjectMapper(); private final SelectAiService selectAiService = new CapturingSelectAiService(); + private final QaVectorService qaVectorService = new CapturingQaVectorService(); private final McpSseService service = new McpSseService( selectAiService, objectMapper, @@ -29,16 +30,23 @@ class McpSseServiceTest { "테스트 데이터의 조회 내용을 입력합니다.", "oracle.select_ai.test_data_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 - void listsQueryAndShowpromptToolsWithPromptInput() { + void listsQueryShowpromptAndQaVectorToolsWithTheirInputs() { ObjectNode response = service.handle("default", request(1, "tools/list")); var tools = response.path("result").path("tools"); - assertThat(tools).hasSize(2); + assertThat(tools).hasSize(4); var selectAi = tools.get(0); assertThat(selectAi.path("name").asText()).isEqualTo("oracle.select_ai.test_data_text2sql"); assertThat(selectAi.path("description").asText()).contains("SGMP_POC_OCI_GPT54MINI"); @@ -58,6 +66,21 @@ class McpSseServiceTest { assertThat(showprompt.path("inputSchema").path("required")) .extracting(node -> node.asText()) .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 @@ -120,6 +143,44 @@ class McpSseServiceTest { .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) { ObjectNode request = objectMapper.createObjectNode(); request.put("jsonrpc", "2.0"); @@ -163,4 +224,35 @@ class McpSseServiceTest { .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"); + } + } }