From 61f7e4c775757c00b518ee6058fda75a4759a731 Mon Sep 17 00:00:00 2001 From: devmrko Date: Wed, 8 Jul 2026 06:43:29 +0900 Subject: [PATCH] [Developer] add KB Select AI Team ORDS MCP tool --- sql/adb/56_kb_select_ai_router_team.sql | 104 +++++++++++++ sql/adb/57_kb_select_ai_router_team_test.sql | 17 +++ sql/adb/58_kb_select_ai_router_api.sql | 57 +++++++ sql/adb/59_kb_select_ai_router_ords.sql | 143 ++++++++++++++++++ .../config/BackofficeProperties.java | 2 +- .../config/OrdsClientConfig.java | 11 ++ .../vpdbackoffice/service/McpSseService.java | 70 +++++++++ .../service/OrdsProbeService.java | 3 +- .../service/SelectAiAgentOrdsService.java | 130 ++++++++++++++++ src/main/resources/application.yml | 1 + .../service/McpSseServiceTest.java | 99 ++++++++++++ 11 files changed, 635 insertions(+), 2 deletions(-) create mode 100644 sql/adb/56_kb_select_ai_router_team.sql create mode 100644 sql/adb/57_kb_select_ai_router_team_test.sql create mode 100644 sql/adb/58_kb_select_ai_router_api.sql create mode 100644 sql/adb/59_kb_select_ai_router_ords.sql create mode 100644 src/main/java/com/cloudhandson/vpdbackoffice/service/SelectAiAgentOrdsService.java create mode 100644 src/test/java/com/cloudhandson/vpdbackoffice/service/McpSseServiceTest.java diff --git a/sql/adb/56_kb_select_ai_router_team.sql b/sql/adb/56_kb_select_ai_router_team.sql new file mode 100644 index 0000000..90a7b46 --- /dev/null +++ b/sql/adb/56_kb_select_ai_router_team.sql @@ -0,0 +1,104 @@ +-- ============================================================ +-- 56_kb_select_ai_router_team.sql +-- +-- KB Select AI profile을 호출하는 최소 Agent / Tool / Task / Team 체인. +-- Tool -> Task -> Router Agent -> Team +-- Execute as POC_2 using SQLcl. +-- ============================================================ +WHENEVER SQLERROR EXIT SQL.SQLCODE +SET DEFINE OFF +SET FEEDBACK ON + +PROMPT === Resetting only this Select AI router chain === +BEGIN + DBMS_CLOUD_AI_AGENT.DROP_TEAM('KB_SELECT_AI_ROUTER_TEAM', force => TRUE); + DBMS_CLOUD_AI_AGENT.DROP_TASK('KB_SELECT_AI_ROUTER_TASK', force => TRUE); + DBMS_CLOUD_AI_AGENT.DROP_AGENT('KB_SELECT_AI_ROUTER_AGENT', force => TRUE); + DBMS_CLOUD_AI_AGENT.DROP_TOOL('KB_SELECT_AI_ROUTER_TOOL', force => TRUE); +END; +/ + +PROMPT === Creating Select AI SQL tool === +BEGIN + DBMS_CLOUD_AI_AGENT.CREATE_TOOL( + tool_name => 'KB_SELECT_AI_ROUTER_TOOL', + attributes => q'~{ + "tool_type": "SQL", + "tool_params": { + "profile_name": "KB_AIDP_SELECTAI_GPT55_OCI_PROFILE_V2" + }, + "instruction": "Use the configured Select AI profile for structured KB insurance questions. For this demo, generate SHOWSQL only: create a read-only SELECT or WITH statement, do not execute it, and never generate DDL, DML, transaction control, package calls, or data changes." + }~', + status => 'ENABLED', + description => 'Calls KB_AIDP_SELECTAI_GPT55_OCI_PROFILE_V2 to generate read-only KB SQL.' + ); +END; +/ + +PROMPT === Creating router agent === +BEGIN + DBMS_CLOUD_AI_AGENT.CREATE_AGENT( + agent_name => 'KB_SELECT_AI_ROUTER_AGENT', + attributes => q'~{ + "profile_name": "KB_AIDP_AGENT_GPT55_OCI_PROFILE_V2", + "role": "You are the KB Select AI routing agent. Classify every user request. If it asks for KB structured insurance data, metrics, counts, contracts, claims, customers, products, coverage, holdings, or stakeholders, call the assigned KB Select AI SQL tool. Return a concise Korean answer and the generated SQL. Do not invent data, do not expose internal reasoning, and do not use any unassigned tool.", + "enable_human_tool": false + }~', + status => 'ENABLED', + description => 'Routes KB structured-data questions to the Select AI SQL tool.' + ); +END; +/ + +PROMPT === Registering tool task === +BEGIN + DBMS_CLOUD_AI_AGENT.CREATE_TASK( + task_name => 'KB_SELECT_AI_ROUTER_TASK', + attributes => q'~{ + "instruction": "Route this user request to the KB Select AI SQL tool when it requires structured insurance data: {query}. Use SHOWSQL only. Explain briefly in Korean what the generated SQL will retrieve. If the request is outside the KB structured-data scope, state that this team only handles KB structured-data SQL generation.", + "tools": ["KB_SELECT_AI_ROUTER_TOOL"], + "enable_human_tool": false + }~', + status => 'ENABLED', + description => 'Single Select AI SQL generation task for KB structured-data requests.' + ); +END; +/ + +PROMPT === Creating agent task team === +BEGIN + DBMS_CLOUD_AI_AGENT.CREATE_TEAM( + team_name => 'KB_SELECT_AI_ROUTER_TEAM', + attributes => q'~{ + "agents": [ + { + "name": "KB_SELECT_AI_ROUTER_AGENT", + "task": "KB_SELECT_AI_ROUTER_TASK" + } + ], + "process": "sequential" + }~', + status => 'ENABLED', + description => 'KB Select AI router Team: SQL tool, task, agent, and team.' + ); +END; +/ + +PROMPT === Registered chain === +SELECT tool_name, status +FROM USER_AI_AGENT_TOOLS +WHERE tool_name = 'KB_SELECT_AI_ROUTER_TOOL'; + +SELECT agent_name, status +FROM USER_AI_AGENTS +WHERE agent_name = 'KB_SELECT_AI_ROUTER_AGENT'; + +SELECT task_name, status +FROM USER_AI_AGENT_TASKS +WHERE task_name = 'KB_SELECT_AI_ROUTER_TASK'; + +SELECT agent_team_name, status +FROM USER_AI_AGENT_TEAMS +WHERE agent_team_name = 'KB_SELECT_AI_ROUTER_TEAM'; + +EXIT diff --git a/sql/adb/57_kb_select_ai_router_team_test.sql b/sql/adb/57_kb_select_ai_router_team_test.sql new file mode 100644 index 0000000..96b8356 --- /dev/null +++ b/sql/adb/57_kb_select_ai_router_team_test.sql @@ -0,0 +1,17 @@ +-- Execute as POC_2 using SQLcl after 56. +WHENEVER SQLERROR EXIT SQL.SQLCODE +SET PAGESIZE 100 +SET LINESIZE 240 +SET LONG 30000 +SET LONGCHUNKSIZE 30000 +SET FEEDBACK ON + +SELECT DBMS_CLOUD_AI_AGENT.RUN_TEAM( + team_name => 'KB_SELECT_AI_ROUTER_TEAM', + user_prompt => 'KB 고객 원장의 전체 고객 수를 계산하는 SQL을 만들어 줘. SQL만 생성하고 실행하지 마.', + params => '{"conversation_id":"kb-select-ai-router-' || + TO_CHAR(SYSTIMESTAMP, 'YYYYMMDDHH24MISSFF3') || '"}' +) AS agent_response +FROM dual; + +EXIT diff --git a/sql/adb/58_kb_select_ai_router_api.sql b/sql/adb/58_kb_select_ai_router_api.sql new file mode 100644 index 0000000..fcdd65a --- /dev/null +++ b/sql/adb/58_kb_select_ai_router_api.sql @@ -0,0 +1,57 @@ +-- ============================================================ +-- 58_kb_select_ai_router_api.sql +-- +-- POC_2 소유 Select AI Team을 ORDS runtime(CB_ORDS)에서 안전하게 +-- 호출하기 위한 definer-rights API. +-- +-- Execute as POC_2 using SQLcl after 56_kb_select_ai_router_team.sql. +-- ============================================================ +WHENEVER SQLERROR EXIT SQL.SQLCODE +SET ECHO OFF +SET FEEDBACK ON +SET DEFINE OFF + +PROMPT === Creating POC_2 Select AI Team API === +CREATE OR REPLACE PACKAGE kb_select_ai_router_api AUTHID DEFINER AS + FUNCTION run_team( + p_prompt IN CLOB, + p_conversation_id IN VARCHAR2 DEFAULT NULL + ) RETURN CLOB; +END kb_select_ai_router_api; +/ + +CREATE OR REPLACE PACKAGE BODY kb_select_ai_router_api AS + FUNCTION run_team( + p_prompt IN CLOB, + p_conversation_id IN VARCHAR2 DEFAULT NULL + ) RETURN CLOB AS + v_params CLOB; + v_conversation_id VARCHAR2(128) := TRIM(p_conversation_id); + BEGIN + IF p_prompt IS NULL OR DBMS_LOB.GETLENGTH(TRIM(p_prompt)) = 0 THEN + RAISE_APPLICATION_ERROR(-20801, 'prompt is required'); + END IF; + + IF v_conversation_id IS NOT NULL THEN + IF NOT REGEXP_LIKE(v_conversation_id, '^[A-Za-z0-9._:-]{1,128}$') THEN + RAISE_APPLICATION_ERROR(-20802, 'conversationId contains unsupported characters'); + END IF; + v_params := '{"conversation_id":"' || v_conversation_id || '"}'; + END IF; + + RETURN DBMS_CLOUD_AI_AGENT.RUN_TEAM( + team_name => 'KB_SELECT_AI_ROUTER_TEAM', + user_prompt => p_prompt, + params => v_params + ); + END run_team; +END kb_select_ai_router_api; +/ + +SHOW ERRORS + +PROMPT === Granting only the Team API to the ORDS runtime === +GRANT EXECUTE ON kb_select_ai_router_api TO cb_ords; + +PROMPT === POC_2 Select AI Team API ready === +EXIT diff --git a/sql/adb/59_kb_select_ai_router_ords.sql b/sql/adb/59_kb_select_ai_router_ords.sql new file mode 100644 index 0000000..cd4fbd4 --- /dev/null +++ b/sql/adb/59_kb_select_ai_router_ords.sql @@ -0,0 +1,143 @@ +-- ============================================================ +-- 59_kb_select_ai_router_ords.sql +-- +-- POST /ords/cb-ords/kb-select-ai-agent/run +-- Authorization: Bearer +-- {"prompt":"...","conversationId":"optional-safe-id"} +-- +-- The ORDS runtime validates the bearer and sets the application context +-- before invoking the POC_2-owned Team API. The context is session scoped, +-- so VPD still applies to any SQL action selected by the Select AI tool. +-- +-- Execute as CB_ORDS after 22 and 58. +-- ============================================================ +WHENEVER SQLERROR EXIT SQL.SQLCODE +SET ECHO OFF +SET FEEDBACK ON +SET DEFINE OFF + +PROMPT === Resetting only the KB Select AI Agent ORDS module === +BEGIN + ORDS.DELETE_MODULE(p_module_name => 'kb.select.ai.agent'); +EXCEPTION + WHEN OTHERS THEN NULL; +END; +/ + +PROMPT === Creating KB Select AI Agent ORDS endpoint === +BEGIN + ORDS.DEFINE_MODULE( + p_module_name => 'kb.select.ai.agent', + p_base_path => 'kb-select-ai-agent/', + p_items_per_page => 0, + p_status => 'PUBLISHED' + ); + + ORDS.DEFINE_TEMPLATE( + p_module_name => 'kb.select.ai.agent', + p_pattern => 'run' + ); + + ORDS.DEFINE_HANDLER( + p_module_name => 'kb.select.ai.agent', + p_pattern => 'run', + p_method => 'POST', + p_source_type => ORDS.source_type_plsql, + p_source => q'~ +DECLARE + v_body_text CLOB; + v_prompt VARCHAR2(32767); + v_requested_conversation_id VARCHAR2(128); + v_internal_conversation_id VARCHAR2(128); + v_stakeholder_user_id VARCHAR2(4000); + v_answer CLOB; +BEGIN + cb_ords_handler_pkg.set_vpd_context(:auth_header, :probe_id); + + -- ORDS exposes request JSON as a stream bind. Read it once only. + v_body_text := :body_text; + v_prompt := JSON_VALUE(v_body_text, '$.prompt' RETURNING VARCHAR2(32767)); + v_requested_conversation_id := JSON_VALUE(v_body_text, '$.conversationId' RETURNING VARCHAR2(128)); + + IF v_prompt IS NULL OR TRIM(v_prompt) IS NULL THEN + RAISE_APPLICATION_ERROR(-20801, 'prompt is required'); + END IF; + IF v_requested_conversation_id IS NULL THEN + v_requested_conversation_id := 'kb-' || LOWER(RAWTOHEX(SYS_GUID())); + ELSIF NOT REGEXP_LIKE(v_requested_conversation_id, '^[A-Za-z0-9._:-]{1,128}$') THEN + RAISE_APPLICATION_ERROR(-20802, 'conversationId contains unsupported characters'); + END IF; + + -- A connection-pooled ORDS session can be reused. Scope the Team's + -- conversation memory to the VPD subject as well as the caller's public ID. + v_stakeholder_user_id := NVL(SYS_CONTEXT('CB_AGENT_CTX', 'STAKEHOLDER_USER_ID'), 'anonymous'); + v_internal_conversation_id := 'kb-' || LOWER(RAWTOHEX(STANDARD_HASH( + v_stakeholder_user_id || ':' || v_requested_conversation_id, + 'SHA256' + ))); + + v_answer := poc_2.kb_select_ai_router_api.run_team( + p_prompt => v_prompt, + p_conversation_id => v_internal_conversation_id + ); + + :status_code := 200; + OWA_UTIL.MIME_HEADER('application/json', FALSE); + HTP.P('Cache-Control: no-store'); + OWA_UTIL.HTTP_HEADER_CLOSE; + APEX_JSON.OPEN_OBJECT; + APEX_JSON.WRITE('team', 'KB_SELECT_AI_ROUTER_TEAM'); + APEX_JSON.WRITE('conversationId', v_requested_conversation_id); + APEX_JSON.WRITE('answer', v_answer); + APEX_JSON.CLOSE_OBJECT; + + cb_ords_handler_pkg.clear_vpd_context; +EXCEPTION + WHEN OTHERS THEN + cb_ords_handler_pkg.clear_vpd_context; + :status_code := CASE + WHEN SQLCODE IN (-20801, -20802) OR SQLCODE BETWEEN -40599 AND -40400 THEN 400 + WHEN SQLCODE BETWEEN -20199 AND -20100 THEN 403 + ELSE 500 + END; + OWA_UTIL.MIME_HEADER('application/json', FALSE); + HTP.P('Cache-Control: no-store'); + OWA_UTIL.HTTP_HEADER_CLOSE; + APEX_JSON.OPEN_OBJECT; + APEX_JSON.WRITE('errorCode', SQLCODE); + APEX_JSON.WRITE('error', SQLERRM); + APEX_JSON.CLOSE_OBJECT; +END; +~', + p_items_per_page => 0 + ); + + ORDS.DEFINE_PARAMETER( + p_module_name => 'kb.select.ai.agent', + p_pattern => 'run', + p_method => 'POST', + p_name => 'Authorization', + p_bind_variable_name => 'auth_header', + p_source_type => 'HEADER', + p_param_type => 'STRING', + p_access_method => 'IN' + ); + + ORDS.DEFINE_PARAMETER( + p_module_name => 'kb.select.ai.agent', + p_pattern => 'run', + p_method => 'POST', + p_name => 'X-VPD-Probe-Id', + p_bind_variable_name => 'probe_id', + p_source_type => 'HEADER', + p_param_type => 'STRING', + p_access_method => 'IN' + ); + + COMMIT; +END; +/ + +PROMPT === KB Select AI Agent ORDS endpoint ready === +PROMPT Path: /ords/cb-ords/kb-select-ai-agent/run +EXIT diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/config/BackofficeProperties.java b/src/main/java/com/cloudhandson/vpdbackoffice/config/BackofficeProperties.java index 442aca2..fa23fdc 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/config/BackofficeProperties.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/config/BackofficeProperties.java @@ -17,7 +17,7 @@ public record BackofficeProperties( public record Token(int maxDays) { } - public record Ords(String baseUrl, Duration timeout) { + public record Ords(String baseUrl, Duration timeout, Duration agentTimeout) { } public record Ai( diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/config/OrdsClientConfig.java b/src/main/java/com/cloudhandson/vpdbackoffice/config/OrdsClientConfig.java index 3430d25..46c4c0f 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/config/OrdsClientConfig.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/config/OrdsClientConfig.java @@ -1,6 +1,7 @@ package com.cloudhandson.vpdbackoffice.config; import java.time.Duration; +import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.boot.web.client.RestTemplateBuilder; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -17,4 +18,14 @@ public class OrdsClientConfig { .setReadTimeout(timeout) .build(); } + + @Bean + @Qualifier("ordsAgentRestTemplate") + RestTemplate ordsAgentRestTemplate(BackofficeProperties properties) { + Duration timeout = properties.ords().agentTimeout(); + return new RestTemplateBuilder() + .setConnectTimeout(timeout) + .setReadTimeout(timeout) + .build(); + } } diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/McpSseService.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/McpSseService.java index e51fa46..1c852aa 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/service/McpSseService.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/service/McpSseService.java @@ -13,17 +13,23 @@ import org.springframework.stereotype.Service; @Service public class McpSseService { + private static final String SELECT_AI_ROUTER_TOOL = "ords.agent.kb_select_ai_router"; + private static final String SELECT_AI_ROUTER_PATH = "cb-ords/kb-select-ai-agent/run"; + private final McpToolRegistry toolRegistry; private final OrdsProbeService ordsProbeService; + private final SelectAiAgentOrdsService selectAiAgentOrdsService; private final ObjectMapper objectMapper; public McpSseService( McpToolRegistry toolRegistry, OrdsProbeService ordsProbeService, + SelectAiAgentOrdsService selectAiAgentOrdsService, ObjectMapper objectMapper ) { this.toolRegistry = toolRegistry; this.ordsProbeService = ordsProbeService; + this.selectAiAgentOrdsService = selectAiAgentOrdsService; this.objectMapper = objectMapper; } @@ -76,6 +82,7 @@ public class McpSseService { item.set("inputSchema", inputSchema(tool)); tools.add(item); } + tools.add(selectAiRouterTool()); result.set("tools", tools); return result; } @@ -116,9 +123,48 @@ public class McpSseService { return schema; } + private ObjectNode selectAiRouterTool() { + ObjectNode item = objectMapper.createObjectNode(); + item.put("name", SELECT_AI_ROUTER_TOOL); + item.put("description", "Bearer Token으로 ORDS Select AI Team을 호출해 KB 원장 질의용 SQL을 생성합니다. Team의 VPD 컨텍스트가 적용됩니다."); + + ObjectNode schema = objectMapper.createObjectNode(); + schema.put("type", "object"); + ObjectNode properties = objectMapper.createObjectNode(); + + ObjectNode bearerToken = objectMapper.createObjectNode(); + bearerToken.put("type", "string"); + bearerToken.put("description", "ORDS 호출에 사용할 Bearer Token 원문"); + properties.set("bearerToken", bearerToken); + + ObjectNode prompt = objectMapper.createObjectNode(); + prompt.put("type", "string"); + prompt.put("description", "KB 원장에 대해 생성할 SQL을 자연어로 요청합니다. 이 Team은 읽기 전용 SHOWSQL 생성만 허용합니다."); + prompt.put("maxLength", 8000); + properties.set("prompt", prompt); + + ObjectNode conversationId = objectMapper.createObjectNode(); + conversationId.put("type", "string"); + conversationId.put("description", "선택값. 동일 대화 흐름을 이어갈 때 사용하는 안전한 식별자"); + conversationId.put("pattern", "^[A-Za-z0-9._:-]{1,128}$"); + properties.set("conversationId", conversationId); + + schema.set("properties", properties); + ArrayNode required = objectMapper.createArrayNode(); + required.add("bearerToken"); + required.add("prompt"); + schema.set("required", required); + schema.put("additionalProperties", false); + item.set("inputSchema", schema); + return item; + } + private ObjectNode toolsCallResult(JsonNode params) { String toolName = params.path("name").asText(""); JsonNode arguments = params.path("arguments"); + if (SELECT_AI_ROUTER_TOOL.equals(toolName)) { + return selectAiRouterCallResult(arguments); + } McpToolView tool = findTool(toolName); String bearerToken = arguments.path("bearerToken").asText(""); int limit = normalizeLimit(arguments.path("limit").asInt(50)); @@ -163,6 +209,30 @@ public class McpSseService { return result; } + private ObjectNode selectAiRouterCallResult(JsonNode arguments) { + JsonNode response = selectAiAgentOrdsService.run( + arguments.path("bearerToken").asText(""), + arguments.path("prompt").asText(""), + arguments.path("conversationId").asText("") + ); + + ObjectNode payload = objectMapper.createObjectNode(); + payload.put("toolName", SELECT_AI_ROUTER_TOOL); + payload.put("team", "KB_SELECT_AI_ROUTER_TEAM"); + payload.put("ordsPath", SELECT_AI_ROUTER_PATH); + payload.set("response", response); + + ObjectNode result = objectMapper.createObjectNode(); + ArrayNode content = objectMapper.createArrayNode(); + ObjectNode text = objectMapper.createObjectNode(); + text.put("type", "text"); + text.put("text", pretty(payload)); + content.add(text); + result.set("content", content); + result.put("isError", false); + return result; + } + private McpToolView findTool(String toolName) { List tools = toolRegistry.listTools(); return tools.stream() diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/OrdsProbeService.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/OrdsProbeService.java index 96f6641..25b569e 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/service/OrdsProbeService.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/service/OrdsProbeService.java @@ -27,6 +27,7 @@ import java.util.Locale; import java.util.Map; import java.util.Set; import java.util.UUID; +import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.jdbc.core.ConnectionCallback; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.http.HttpEntity; @@ -84,7 +85,7 @@ public class OrdsProbeService { ProtectedObjectService protectedObjectService, AuditService auditService, ProbeErrorClassifier errorClassifier, - RestTemplate ordsRestTemplate, + @Qualifier("ordsRestTemplate") RestTemplate ordsRestTemplate, ObjectMapper objectMapper, SettingService settingService, JdbcTemplate jdbcTemplate, diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/SelectAiAgentOrdsService.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/SelectAiAgentOrdsService.java new file mode 100644 index 0000000..db48a3b --- /dev/null +++ b/src/main/java/com/cloudhandson/vpdbackoffice/service/SelectAiAgentOrdsService.java @@ -0,0 +1,130 @@ +package com.cloudhandson.vpdbackoffice.service; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import java.net.URI; +import java.util.UUID; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; +import org.springframework.web.client.HttpStatusCodeException; +import org.springframework.web.client.ResourceAccessException; +import org.springframework.web.client.RestTemplate; +import org.springframework.web.util.UriComponentsBuilder; + +/** Calls the ORDS boundary; the database endpoint owns bearer-to-VPD-context mapping. */ +@Service +public class SelectAiAgentOrdsService { + + static final String ORDS_PATH = "/cb-ords/kb-select-ai-agent/run"; + private static final int MAX_PROMPT_LENGTH = 8_000; + + private final SettingService settingService; + private final RestTemplate restTemplate; + private final ObjectMapper objectMapper; + + public SelectAiAgentOrdsService( + SettingService settingService, + @Qualifier("ordsAgentRestTemplate") RestTemplate restTemplate, + ObjectMapper objectMapper + ) { + this.settingService = settingService; + this.restTemplate = restTemplate; + this.objectMapper = objectMapper; + } + + public JsonNode run(String bearerToken, String prompt, String conversationId) { + String normalizedToken = required(bearerToken, "bearerToken"); + String normalizedPrompt = required(prompt, "prompt"); + if (normalizedPrompt.length() > MAX_PROMPT_LENGTH) { + throw new AppException("prompt는 " + MAX_PROMPT_LENGTH + "자 이하여야 합니다."); + } + String normalizedConversationId = normalizeConversationId(conversationId); + String baseUrl = settingService.ordsBaseUrl(); + if (baseUrl == null || baseUrl.isBlank()) { + throw new AppException("ORDS base URL이 설정되지 않았습니다."); + } + + ObjectNode requestBody = objectMapper.createObjectNode(); + requestBody.put("prompt", normalizedPrompt); + if (normalizedConversationId != null) { + requestBody.put("conversationId", normalizedConversationId); + } + + HttpHeaders headers = new HttpHeaders(); + headers.setBearerAuth(normalizedToken); + headers.setContentType(MediaType.APPLICATION_JSON); + headers.set("X-VPD-Probe-Id", UUID.randomUUID().toString()); + + try { + ResponseEntity response = restTemplate.exchange( + endpoint(baseUrl), + HttpMethod.POST, + new HttpEntity<>(requestBody.toString(), headers), + String.class + ); + JsonNode body = parse(response.getBody()); + if (body.hasNonNull("error")) { + throw new AppException("Select AI Agent ORDS 오류: " + body.path("error").asText()); + } + return body; + } catch (HttpStatusCodeException e) { + throw new AppException("Select AI Agent ORDS HTTP " + e.getStatusCode().value() + + ": " + responseError(e.getResponseBodyAsString())); + } catch (ResourceAccessException e) { + throw new AppException("Select AI Agent ORDS 연결 또는 응답 시간 초과: " + e.getMessage()); + } + } + + private URI endpoint(String baseUrl) { + return UriComponentsBuilder.fromUriString(baseUrl) + .path(ORDS_PATH) + .build() + .toUri(); + } + + private JsonNode parse(String value) { + try { + if (value == null || value.isBlank()) { + throw new AppException("Select AI Agent ORDS 응답 본문이 비어 있습니다."); + } + return objectMapper.readTree(value); + } catch (AppException e) { + throw e; + } catch (Exception e) { + throw new AppException("Select AI Agent ORDS 응답 JSON 파싱 실패: " + e.getMessage()); + } + } + + private String responseError(String body) { + try { + JsonNode parsed = objectMapper.readTree(body); + return parsed.path("error").asText(body == null ? "" : body); + } catch (Exception ignored) { + return body == null ? "" : body; + } + } + + private String required(String value, String name) { + if (value == null || value.isBlank()) { + throw new AppException(name + "은(는) 필수입니다."); + } + return value.trim(); + } + + private String normalizeConversationId(String value) { + if (value == null || value.isBlank()) { + return null; + } + String normalized = value.trim(); + if (!normalized.matches("[A-Za-z0-9._:-]{1,128}")) { + throw new AppException("conversationId는 영문/숫자/._:-만 사용하고 128자 이하여야 합니다."); + } + return normalized; + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index f0f72ba..b74ee3d 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -42,6 +42,7 @@ backoffice: ords: base-url: ${BACKOFFICE_ORDS_BASE_URL:https://yh0olybn5pqce4n-d8aukro81636mon0.adb.ap-seoul-1.oraclecloudapps.com/ords} timeout: ${BACKOFFICE_ORDS_TIMEOUT_SECONDS:10}s + agent-timeout: ${BACKOFFICE_ORDS_AGENT_TIMEOUT_SECONDS:180}s metadata-db: url: ${BACKOFFICE_ORDS_DB_URL:} username: ${BACKOFFICE_ORDS_DB_USERNAME:} diff --git a/src/test/java/com/cloudhandson/vpdbackoffice/service/McpSseServiceTest.java b/src/test/java/com/cloudhandson/vpdbackoffice/service/McpSseServiceTest.java new file mode 100644 index 0000000..bacef8c --- /dev/null +++ b/src/test/java/com/cloudhandson/vpdbackoffice/service/McpSseServiceTest.java @@ -0,0 +1,99 @@ +package com.cloudhandson.vpdbackoffice.service; + +import static org.assertj.core.api.Assertions.assertThat; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import java.util.List; +import org.junit.jupiter.api.Test; + +class McpSseServiceTest { + + private final ObjectMapper objectMapper = new ObjectMapper(); + private final SelectAiAgentOrdsService selectAiAgentOrdsService = new CapturingSelectAiAgentOrdsService(); + private final McpSseService service = new McpSseService( + new EmptyToolRegistry(), + null, + selectAiAgentOrdsService, + objectMapper + ); + + @Test + void listsSelectAiRouterToolWithPromptAndBearerInputs() { + ObjectNode response = service.handle("default", request(1, "tools/list")); + + var tools = response.path("result").path("tools"); + var tool = tools.findValuesAsText("name").indexOf("ords.agent.kb_select_ai_router"); + assertThat(tool).isGreaterThanOrEqualTo(0); + var router = tools.get(tool); + assertThat(router.path("inputSchema").path("required")) + .extracting(node -> node.asText()) + .contains("bearerToken", "prompt"); + assertThat(router.path("inputSchema").path("properties").has("conversationId")).isTrue(); + } + + @Test + void callsSelectAiRouterThroughOrdsService() { + ObjectNode request = request(2, "tools/call"); + ObjectNode params = (ObjectNode) request.putObject("params"); + params.put("name", "ords.agent.kb_select_ai_router"); + ObjectNode arguments = params.putObject("arguments"); + arguments.put("bearerToken", "user-bearer"); + arguments.put("prompt", "고객 수를 세는 SQL을 만들어줘"); + arguments.put("conversationId", "smoke-1"); + + ObjectNode response = service.handle("default", request); + + CapturingSelectAiAgentOrdsService agentService = + (CapturingSelectAiAgentOrdsService) selectAiAgentOrdsService; + assertThat(agentService.bearerToken).isEqualTo("user-bearer"); + assertThat(agentService.prompt).isEqualTo("고객 수를 세는 SQL을 만들어줘"); + assertThat(agentService.conversationId).isEqualTo("smoke-1"); + assertThat(response.path("error").isMissingNode()).isTrue(); + assertThat(response.path("result").path("isError").asBoolean()).isFalse(); + assertThat(response.path("result").path("content").get(0).path("text").asText()) + .contains("KB_SELECT_AI_ROUTER_TEAM") + .contains("SELECT COUNT(*) FROM KB_CUSTOMERS"); + } + + private ObjectNode request(int id, String method) { + ObjectNode request = objectMapper.createObjectNode(); + request.put("jsonrpc", "2.0"); + request.put("id", id); + request.put("method", method); + return request; + } + + private static final class EmptyToolRegistry extends McpToolRegistry { + + private EmptyToolRegistry() { + super(null); + } + + @Override + public List listTools() { + return List.of(); + } + } + + private static final class CapturingSelectAiAgentOrdsService extends SelectAiAgentOrdsService { + + private String bearerToken; + private String prompt; + private String conversationId; + + private CapturingSelectAiAgentOrdsService() { + super(null, null, new ObjectMapper()); + } + + @Override + public JsonNode run(String bearerToken, String prompt, String conversationId) { + this.bearerToken = bearerToken; + this.prompt = prompt; + this.conversationId = conversationId; + return new ObjectMapper().createObjectNode() + .put("team", "KB_SELECT_AI_ROUTER_TEAM") + .put("answer", "SELECT COUNT(*) FROM KB_CUSTOMERS"); + } + } +}