refs #731: add QA vector MCP tools
This commit is contained in:
10
.env.example
10
.env.example
@@ -70,6 +70,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/ 아래 파일명만 허용됩니다.
|
||||||
|
|||||||
50
docs/design/727-sgmp-qa-vector-mcp-tools/README.md
Normal file
50
docs/design/727-sgmp-qa-vector-mcp-tools/README.md
Normal 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 입력·응답 계약을 검증한다.
|
||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
@@ -91,7 +91,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>
|
||||||
@@ -107,10 +107,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>
|
||||||
|
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user