diff --git a/docs/design/735-sgmp-fewshot-nl2sql-mcp/README.md b/docs/design/735-sgmp-fewshot-nl2sql-mcp/README.md new file mode 100644 index 0000000..7a255e2 --- /dev/null +++ b/docs/design/735-sgmp-fewshot-nl2sql-mcp/README.md @@ -0,0 +1,37 @@ +# SGMP Few-shot NL2SQL MCP (#735) + +> 상태: Approved +> 구현: `McpSseService`, `SelectAiService`, `McpProperties` + +## 목적 + +기존 Select AI Text2SQL 경로와 분리된 검증용 MCP tool을 제공한다. 질문과 유사한 검토 완료 예제를 벡터 검색하고, 그 SQL 패턴을 prompt에 참고자료로 넣은 뒤 `SHOWSQL`로 생성한 SQL을 읽기 전용으로 실행한다. + +## MCP 계약 + +- **tool name**: `oracle.select_ai.smilegate_fewshot_nl2sql` (환경변수로 재정의 가능) +- **입력**: `prompt` (최대 4,000자) +- **출력**: 벡터 Few-shot 예제(질문, SQL, 모델, cosine distance), 생성 SQL, 실행 상태, 행 수, 최대 100건 결과 + +Few-shot SQL은 실행하지 않는다. 현재 메타데이터·alias 정책을 우선하고, Select AI가 새로 생성한 SQL만 read-only 검증 후 실행한다. + +## 안전 규칙 + +1. 벡터 검색 실패는 `UNAVAILABLE` 상태로 남기고 기존 정책 prompt로 폴백한다. +2. 생성 결과는 단일 `SELECT` 또는 `WITH`만 허용한다. +3. DDL, DML, PL/SQL, 시스템 객체, 잠금 구문, 다중 문장은 차단한다. +4. JDBC read-only 트랜잭션과 30초 query timeout, 최대 100행 제한을 적용한다. +5. 기존 `data_text2sql`, `data_showprompt`, `qa_vector_search`, `qa_vector_store` tool은 변경하지 않는다. + +## 설정 + +```text +BACKOFFICE_MCP_FEW_SHOT_NL2SQL_TOOL_NAME +BACKOFFICE_MCP_FEW_SHOT_NL2SQL_TOOL_LABEL +BACKOFFICE_MCP_FEW_SHOT_NL2SQL_TOOL_DESCRIPTION +``` + +## 추적성 + +- Redmine: #735 +- 테스트: `McpSseServiceTest` diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/config/McpProperties.java b/src/main/java/com/cloudhandson/vpdbackoffice/config/McpProperties.java index 4f0333f..5ae2040 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/config/McpProperties.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/config/McpProperties.java @@ -17,7 +17,10 @@ public record McpProperties( String qaVectorSearchToolDescription, String qaVectorStoreToolName, String qaVectorStoreToolLabel, - String qaVectorStoreToolDescription + String qaVectorStoreToolDescription, + String fewShotNl2SqlToolName, + String fewShotNl2SqlToolLabel, + String fewShotNl2SqlToolDescription ) { private static final String DEFAULT_TOOL_NAME = "oracle.select_ai.data_text2sql"; @@ -46,6 +49,13 @@ public record McpProperties( private static final String DEFAULT_QA_VECTOR_STORE_TOOL_DESCRIPTION = "검토된 Select AI 결과를 후속 Text2SQL 품질 향상용 예제 SQL로 저장합니다. " + "질문과 읽기 전용 답 SQL이 필요합니다."; + private static final String DEFAULT_FEW_SHOT_NL2SQL_TOOL_NAME = + "oracle.select_ai.smilegate_fewshot_nl2sql"; + private static final String DEFAULT_FEW_SHOT_NL2SQL_TOOL_LABEL = + "Few-shot NL2SQL 실행"; + private static final String DEFAULT_FEW_SHOT_NL2SQL_TOOL_DESCRIPTION = + "벡터 Few-shot 예제를 찾아 prompt에 반영하고, SHOWSQL로 생성한 읽기 전용 SQL을 실행합니다. " + + "Few-shot 근거, 생성 SQL, 실행 결과를 함께 반환합니다."; public String resolvedToolName() { return requiredOrDefault(toolName, DEFAULT_TOOL_NAME); @@ -99,6 +109,18 @@ public record McpProperties( return requiredOrDefault(qaVectorStoreToolDescription, DEFAULT_QA_VECTOR_STORE_TOOL_DESCRIPTION); } + public String resolvedFewShotNl2SqlToolName() { + return requiredOrDefault(fewShotNl2SqlToolName, DEFAULT_FEW_SHOT_NL2SQL_TOOL_NAME); + } + + public String resolvedFewShotNl2SqlToolLabel() { + return requiredOrDefault(fewShotNl2SqlToolLabel, DEFAULT_FEW_SHOT_NL2SQL_TOOL_LABEL); + } + + public String resolvedFewShotNl2SqlToolDescription() { + return requiredOrDefault(fewShotNl2SqlToolDescription, DEFAULT_FEW_SHOT_NL2SQL_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 9f8ca59..110cfaa 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/service/McpSseService.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/service/McpSseService.java @@ -73,7 +73,8 @@ public class McpSseService { /** Tools registered by this MCP server. */ public List registeredTools() { return List.of( - selectAiQueryView(), selectAiShowpromptView(), qaVectorSearchView(), qaVectorStoreView()); + selectAiQueryView(), selectAiShowpromptView(), qaVectorSearchView(), + qaVectorStoreView(), fewShotNl2SqlView()); } private ObjectNode initializeResult(String contextPath) { @@ -96,6 +97,7 @@ public class McpSseService { tools.add(toolDefinition(selectAiShowpromptView())); tools.add(toolDefinition(qaVectorSearchView())); tools.add(toolDefinition(qaVectorStoreView())); + tools.add(toolDefinition(fewShotNl2SqlView())); result.set("tools", tools); return result; } @@ -149,7 +151,8 @@ public class McpSseService { boolean showpromptTool = showpromptToolName().equals(calledToolName); boolean qaVectorSearchTool = qaVectorSearchToolName().equals(calledToolName); boolean qaVectorStoreTool = qaVectorStoreToolName().equals(calledToolName); - if (!queryTool && !showpromptTool && !qaVectorSearchTool && !qaVectorStoreTool) { + boolean fewShotNl2SqlTool = fewShotNl2SqlToolName().equals(calledToolName); + if (!queryTool && !showpromptTool && !qaVectorSearchTool && !qaVectorStoreTool && !fewShotNl2SqlTool) { throw new AppException("등록되지 않은 MCP tool입니다: " + calledToolName); } @@ -172,7 +175,7 @@ public class McpSseService { )); } else { String prompt = arguments.path("prompt").asText(""); - response = queryTool + response = queryTool || fewShotNl2SqlTool ? selectAiService.generateAndExecute(token, prompt) : selectAiService.generatePrompt(token, prompt); } @@ -278,6 +281,12 @@ public class McpSseService { qaVectorStoreToolLabel(), SELECT_AI_TOOL_PATH); } + private McpToolView fewShotNl2SqlView() { + return new McpToolView( + fewShotNl2SqlToolName(), fewShotNl2SqlToolDescription(), -1L, + fewShotNl2SqlToolLabel(), SELECT_AI_TOOL_PATH); + } + private String selectAiProfile() { BackofficeProperties.SelectAi selectAi = properties == null ? null : properties.selectAi(); if (selectAi == null || selectAi.profile() == null || selectAi.profile().isBlank()) { @@ -354,6 +363,22 @@ public class McpSseService { : mcpProperties.resolvedQaVectorStoreToolDescription(); } + private String fewShotNl2SqlToolName() { + return mcpProperties == null ? "oracle.select_ai.smilegate_fewshot_nl2sql" + : mcpProperties.resolvedFewShotNl2SqlToolName(); + } + + private String fewShotNl2SqlToolLabel() { + return mcpProperties == null ? "Few-shot NL2SQL 실행" + : mcpProperties.resolvedFewShotNl2SqlToolLabel(); + } + + private String fewShotNl2SqlToolDescription() { + return mcpProperties == null + ? "벡터 Few-shot 예제를 찾아 prompt에 반영하고 SHOWSQL로 생성한 읽기 전용 SQL을 실행합니다. Few-shot 근거, 생성 SQL, 실행 결과를 함께 반환합니다." + : mcpProperties.resolvedFewShotNl2SqlToolDescription(); + } + private String pretty(Object value) { try { return objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(value); diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/SelectAiService.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/SelectAiService.java index 65e64e0..526198a 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/service/SelectAiService.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/service/SelectAiService.java @@ -78,6 +78,7 @@ public class SelectAiService { response.put("originalPrompt", normalizedPrompt); response.put("fewShotStatus", enrichedPrompt.status()); response.put("fewShotExampleCount", enrichedPrompt.exampleCount()); + addFewShotExamples(response, enrichedPrompt.examples()); response.put("generatedSql", normalizedSql); response.put("execution", "READ_ONLY_EXECUTED"); response.put("rowCount", execution.items().size()); @@ -89,6 +90,21 @@ public class SelectAiService { return response; } + private void addFewShotExamples(ObjectNode response, List examples) { + ArrayNode items = response.putArray("fewShotExamples"); + for (QaVectorService.VectorExample example : examples) { + ObjectNode item = items.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()); + } + } + /** Returns the prompt Select AI assembled for SQL generation without executing generated SQL. */ public JsonNode generatePrompt(String bearerToken, String prompt) { requireActiveToken(bearerToken); @@ -171,20 +187,21 @@ public class SelectAiService { String prompt ) { if (!fewShotEnabled(selectAi) || qaVectorService == null) { - return new EnrichedPrompt(prompt, "DISABLED", 0); + return new EnrichedPrompt(prompt, "DISABLED", 0, List.of()); } try { List examples = qaVectorService .search(bearerToken, prompt, fewShotTopK(selectAi)) .examples(); if (examples.isEmpty()) { - return new EnrichedPrompt(composePolicyPrompt(prompt), "NO_MATCH", 0); + return new EnrichedPrompt(composePolicyPrompt(prompt), "NO_MATCH", 0, List.of()); } return new EnrichedPrompt( - composeFewShotPrompt(prompt, examples), "APPLIED", Math.min(examples.size(), MAX_FEW_SHOT_EXAMPLES)); + composeFewShotPrompt(prompt, examples), "APPLIED", Math.min(examples.size(), MAX_FEW_SHOT_EXAMPLES), + examples.subList(0, Math.min(examples.size(), MAX_FEW_SHOT_EXAMPLES))); } catch (Exception ignored) { // Vector retrieval is an optional prompt aid; preserve the normal Text2SQL path on failure. - return new EnrichedPrompt(composePolicyPrompt(prompt), "UNAVAILABLE", 0); + return new EnrichedPrompt(composePolicyPrompt(prompt), "UNAVAILABLE", 0, List.of()); } } @@ -325,5 +342,6 @@ public class SelectAiService { private record QueryExecution(ArrayNode items, boolean truncated) {} - private record EnrichedPrompt(String prompt, String status, int exampleCount) {} + private record EnrichedPrompt( + String prompt, String status, int exampleCount, List examples) {} } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 3df1f2f..62e317f 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -98,6 +98,9 @@ backoffice: 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이 필요합니다.} + few-shot-nl2sql-tool-name: ${BACKOFFICE_MCP_FEW_SHOT_NL2SQL_TOOL_NAME:oracle.select_ai.smilegate_fewshot_nl2sql} + few-shot-nl2sql-tool-label: ${BACKOFFICE_MCP_FEW_SHOT_NL2SQL_TOOL_LABEL:Few-shot NL2SQL 실행} + few-shot-nl2sql-tool-description: ${BACKOFFICE_MCP_FEW_SHOT_NL2SQL_TOOL_DESCRIPTION:벡터 Few-shot 예제를 찾아 prompt에 반영하고 SHOWSQL로 생성한 읽기 전용 SQL을 실행합니다. Few-shot 근거, 생성 SQL, 실행 결과를 함께 반환합니다.} masking: policies: ${BACKOFFICE_MASKING_POLICIES:} security-sql-scripts: diff --git a/src/test/java/com/cloudhandson/vpdbackoffice/service/McpSseServiceTest.java b/src/test/java/com/cloudhandson/vpdbackoffice/service/McpSseServiceTest.java index 45147bf..ad0814f 100644 --- a/src/test/java/com/cloudhandson/vpdbackoffice/service/McpSseServiceTest.java +++ b/src/test/java/com/cloudhandson/vpdbackoffice/service/McpSseServiceTest.java @@ -37,7 +37,10 @@ class McpSseServiceTest { "테스트 질문에 사용할 예제 SQL을 조회합니다.", "oracle.select_ai.test_qa_vector_store", "테스트 예제 SQL 저장", - "테스트 예제 SQL을 저장합니다." + "테스트 예제 SQL을 저장합니다.", + "oracle.select_ai.test_fewshot_nl2sql", + "테스트 Few-shot NL2SQL", + "테스트 Few-shot 검색 및 실행 도구입니다." ), qaVectorService ); @@ -47,7 +50,7 @@ class McpSseServiceTest { ObjectNode response = service.handle("default", request(1, "tools/list")); var tools = response.path("result").path("tools"); - assertThat(tools).hasSize(4); + assertThat(tools).hasSize(5); 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"); @@ -82,6 +85,11 @@ class McpSseServiceTest { assertThat(vectorStore.path("inputSchema").path("required")) .extracting(node -> node.asText()) .contains("question", "answerSql"); + + var fewShot = tools.get(4); + assertThat(fewShot.path("name").asText()) + .isEqualTo("oracle.select_ai.test_fewshot_nl2sql"); + assertThat(fewShot.path("description").asText()).contains("Few-shot"); } @Test @@ -128,6 +136,24 @@ class McpSseServiceTest { .contains("RESULT"); } + @Test + void callsDedicatedFewShotNl2SqlToolWithGeneratedSqlAndExecutionResult() { + ObjectNode request = request(7, "tools/call"); + ObjectNode params = (ObjectNode) request.putObject("params"); + params.put("name", "oracle.select_ai.test_fewshot_nl2sql"); + params.putObject("arguments").put("prompt", "카제나 AU를 조회해 줘"); + + ObjectNode response = service.handle("default", request, "user-bearer"); + + assertThat(response.path("result").path("isError").asBoolean()).isFalse(); + String payload = response.path("result").path("content").get(0).path("text").asText(); + assertThat(payload) + .contains("oracle.select_ai.test_fewshot_nl2sql") + .contains("SHOWSQL_AND_EXECUTED") + .contains("generatedSql") + .contains("READ_ONLY_EXECUTED"); + } + @Test void returnsToolLevelDeniedResultWhenVpdTokenIsMissing() { ObjectNode request = request(3, "tools/call");