diff --git a/.env.example b/.env.example index f10b0ef..7e3f030 100644 --- a/.env.example +++ b/.env.example @@ -58,6 +58,8 @@ export BACKOFFICE_SELECT_AI_DB_URL="${BACKOFFICE_DB_URL}" export BACKOFFICE_SELECT_AI_DB_USERNAME="" export BACKOFFICE_SELECT_AI_DB_PASSWORD="" export BACKOFFICE_SELECT_AI_PROFILE="" +export BACKOFFICE_SELECT_AI_FEW_SHOT_ENABLED="true" +export BACKOFFICE_SELECT_AI_FEW_SHOT_TOP_K="3" # 공통 데이터 카탈로그. objects는 key/tableName/objectType/businessName/description JSON 배열입니다. # 배포 환경마다 반드시 실제 소유자와 허용 객체를 지정합니다. diff --git a/docs/design/727-sgmp-qa-vector-mcp-tools/README.md b/docs/design/727-sgmp-qa-vector-mcp-tools/README.md index 8bf06db..95bc4a3 100644 --- a/docs/design/727-sgmp-qa-vector-mcp-tools/README.md +++ b/docs/design/727-sgmp-qa-vector-mcp-tools/README.md @@ -37,9 +37,13 @@ 3. 기존 Text2SQL 도구로 SQL을 생성·검토·실행한다. 4. 검토 통과한 질문·생성 SQL·필요 시 답변을 `qa_vector_store`로 저장한다. -현재 MCP는 검색 결과를 반환한다. Text2SQL tool의 내부 Select AI prompt에 -자동 주입하는 변경은 별도 단계로 두어, 검색 결과와 실제 prompt 구성을 -운영자가 먼저 확인할 수 있게 한다. +Text2SQL은 `BACKOFFICE_SELECT_AI_FEW_SHOT_ENABLED`가 true일 때 검색 결과의 +상위 `BACKOFFICE_SELECT_AI_FEW_SHOT_TOP_K`개(기본 3, 최대 3)를 내부 프롬프트에 +자동 보강한다. 예제는 현재 object list·게임 별칭 해석·정책을 대체하지 않으며, +보강 실패 또는 일치 예제 없음은 기존 Text2SQL 경로를 중단시키지 않는다. + +고객 질문 재평가에서 FAIL이 확인되면 기준 SQL을 검토한 뒤에만 저장하고, 같은 +질문을 다시 실행해 `fewShotStatus=APPLIED` 및 판정 개선 여부를 기록한다. ## Smilegate 포털 allowlist diff --git a/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/config/BackofficeProperties.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/config/BackofficeProperties.java index c980efc..904e850 100644 --- a/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/config/BackofficeProperties.java +++ b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/config/BackofficeProperties.java @@ -79,9 +79,15 @@ public record BackofficeProperties( String dbUrl, String dbUsername, String dbPassword, - String profile + String profile, + Boolean fewShotEnabled, + Integer fewShotTopK ) { + public SelectAi(String dbUrl, String dbUsername, String dbPassword, String profile) { + this(dbUrl, dbUsername, dbPassword, profile, true, 3); + } + public boolean configured() { return dbUrl != null && !dbUrl.isBlank() && dbUsername != null && !dbUsername.isBlank() diff --git a/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/SelectAiService.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/SelectAiService.java index 9544f02..548056e 100644 --- a/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/SelectAiService.java +++ b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/SelectAiService.java @@ -17,6 +17,7 @@ import java.sql.Statement; import java.time.Clock; import java.time.LocalDateTime; import java.time.ZoneId; +import java.util.List; import java.util.regex.Pattern; import org.springframework.stereotype.Service; @@ -29,6 +30,9 @@ public class SelectAiService { private static final int MAX_PROMPT_LENGTH = 4_000; private static final int MAX_RESULT_ROWS = 100; private static final int QUERY_TIMEOUT_SECONDS = 30; + private static final int MAX_FEW_SHOT_EXAMPLES = 3; + private static final int MAX_FEW_SHOT_SQL_CHARS = 4_000; + private static final int MAX_ENRICHED_PROMPT_LENGTH = 16_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|for\\s+update|" @@ -39,17 +43,20 @@ public class SelectAiService { private final BearerTokenService bearerTokenService; private final Clock clock; private final ObjectMapper objectMapper; + private final QaVectorService qaVectorService; public SelectAiService( BackofficeProperties properties, BearerTokenService bearerTokenService, Clock clock, - ObjectMapper objectMapper + ObjectMapper objectMapper, + QaVectorService qaVectorService ) { this.properties = properties; this.bearerTokenService = bearerTokenService; this.clock = clock; this.objectMapper = objectMapper; + this.qaVectorService = qaVectorService; } public JsonNode generateAndExecute(String bearerToken, String prompt) { @@ -57,12 +64,16 @@ public class SelectAiService { String normalizedPrompt = requiredPrompt(prompt); BackofficeProperties.SelectAi selectAi = requiredSelectAi(); - String generatedSql = generate(selectAi, normalizedPrompt, "showsql"); + EnrichedPrompt enrichedPrompt = enrichWithFewShot(bearerToken, selectAi, normalizedPrompt); + String generatedSql = generate(selectAi, enrichedPrompt.prompt(), "showsql"); String normalizedSql = validateReadOnlySql(generatedSql); QueryExecution execution = executeReadOnly(selectAi, normalizedSql); ObjectNode response = objectMapper.createObjectNode(); response.put("status", "SHOWSQL_AND_EXECUTED"); response.put("profile", selectAi.profile()); + response.put("originalPrompt", normalizedPrompt); + response.put("fewShotStatus", enrichedPrompt.status()); + response.put("fewShotExampleCount", enrichedPrompt.exampleCount()); response.put("generatedSql", normalizedSql); response.put("execution", "READ_ONLY_EXECUTED"); response.put("rowCount", execution.items().size()); @@ -79,11 +90,15 @@ public class SelectAiService { requireActiveToken(bearerToken); String normalizedPrompt = requiredPrompt(prompt); BackofficeProperties.SelectAi selectAi = requiredSelectAi(); - String selectAiPrompt = generate(selectAi, normalizedPrompt, "showprompt"); + EnrichedPrompt enrichedPrompt = enrichWithFewShot(bearerToken, selectAi, normalizedPrompt); + String selectAiPrompt = generate(selectAi, enrichedPrompt.prompt(), "showprompt"); ObjectNode response = objectMapper.createObjectNode(); response.put("status", "SHOWPROMPT"); response.put("profile", selectAi.profile()); + response.put("originalPrompt", normalizedPrompt); + response.put("fewShotStatus", enrichedPrompt.status()); + response.put("fewShotExampleCount", enrichedPrompt.exampleCount()); response.put("selectAiPrompt", selectAiPrompt); return response; } @@ -146,6 +161,75 @@ public class SelectAiService { } } + private EnrichedPrompt enrichWithFewShot( + String bearerToken, + BackofficeProperties.SelectAi selectAi, + String prompt + ) { + if (!fewShotEnabled(selectAi) || qaVectorService == null) { + return new EnrichedPrompt(prompt, "DISABLED", 0); + } + try { + List examples = qaVectorService + .search(bearerToken, prompt, fewShotTopK(selectAi)) + .examples(); + if (examples.isEmpty()) { + return new EnrichedPrompt(prompt, "NO_MATCH", 0); + } + return new EnrichedPrompt( + composeFewShotPrompt(prompt, examples), "APPLIED", 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(prompt, "UNAVAILABLE", 0); + } + } + + static String composeFewShotPrompt(String prompt, List examples) { + StringBuilder enriched = new StringBuilder( + "Answer the original user question using the current approved object list and policy. " + + "The verified examples below are guidance only: use only relevant SQL patterns, do not invent " + + "identifiers, and do not override current metadata or game-alias resolution policy.\n\n" + + "Verified few-shot examples:\n"); + int included = 0; + for (QaVectorService.VectorExample example : examples) { + if (included >= MAX_FEW_SHOT_EXAMPLES) { + break; + } + String answerSql = truncate(example.answerSql(), MAX_FEW_SHOT_SQL_CHARS); + if (answerSql.isBlank()) { + continue; + } + String candidate = "Example " + (included + 1) + " question:\n" + example.question() + + "\nExample " + (included + 1) + " verified SQL:\n" + answerSql + "\n\n"; + if (enriched.length() + candidate.length() + prompt.length() > MAX_ENRICHED_PROMPT_LENGTH) { + break; + } + enriched.append(candidate); + included++; + } + if (included == 0) { + return prompt; + } + return enriched.append("Original user question:\n").append(prompt).toString(); + } + + private static String truncate(String value, int maxLength) { + String normalized = value == null ? "" : value.trim(); + return normalized.length() <= maxLength ? normalized : normalized.substring(0, maxLength); + } + + private boolean fewShotEnabled(BackofficeProperties.SelectAi selectAi) { + return selectAi.fewShotEnabled() == null || selectAi.fewShotEnabled(); + } + + private int fewShotTopK(BackofficeProperties.SelectAi selectAi) { + Integer configured = selectAi.fewShotTopK(); + if (configured == null) { + return MAX_FEW_SHOT_EXAMPLES; + } + return Math.max(1, Math.min(configured, MAX_FEW_SHOT_EXAMPLES)); + } + private QueryExecution executeReadOnly(BackofficeProperties.SelectAi selectAi, String generatedSql) { ArrayNode items = objectMapper.createArrayNode(); boolean truncated = false; @@ -233,4 +317,6 @@ public class SelectAiService { } private record QueryExecution(ArrayNode items, boolean truncated) {} + + private record EnrichedPrompt(String prompt, String status, int exampleCount) {} } diff --git a/vpd-backoffice/src/main/resources/application.yml b/vpd-backoffice/src/main/resources/application.yml index 5d7350b..7131b7e 100644 --- a/vpd-backoffice/src/main/resources/application.yml +++ b/vpd-backoffice/src/main/resources/application.yml @@ -75,6 +75,8 @@ backoffice: db-username: ${BACKOFFICE_SELECT_AI_DB_USERNAME:} db-password: ${BACKOFFICE_SELECT_AI_DB_PASSWORD:} profile: ${BACKOFFICE_SELECT_AI_PROFILE:} + few-shot-enabled: ${BACKOFFICE_SELECT_AI_FEW_SHOT_ENABLED:true} + few-shot-top-k: ${BACKOFFICE_SELECT_AI_FEW_SHOT_TOP_K:3} catalog: owner: ${BACKOFFICE_CATALOG_OWNER:} objects: ${BACKOFFICE_CATALOG_OBJECTS:} diff --git a/vpd-backoffice/src/test/java/com/cloudhandson/vpdbackoffice/service/McpSseServiceTest.java b/vpd-backoffice/src/test/java/com/cloudhandson/vpdbackoffice/service/McpSseServiceTest.java index 15c86bf..93335f6 100644 --- a/vpd-backoffice/src/test/java/com/cloudhandson/vpdbackoffice/service/McpSseServiceTest.java +++ b/vpd-backoffice/src/test/java/com/cloudhandson/vpdbackoffice/service/McpSseServiceTest.java @@ -196,7 +196,7 @@ class McpSseServiceTest { private boolean showpromptCalled; private CapturingSelectAiService() { - super(null, null, null, new ObjectMapper()); + super(null, null, null, new ObjectMapper(), null); } @Override diff --git a/vpd-backoffice/src/test/java/com/cloudhandson/vpdbackoffice/service/SelectAiFewShotPromptTest.java b/vpd-backoffice/src/test/java/com/cloudhandson/vpdbackoffice/service/SelectAiFewShotPromptTest.java new file mode 100644 index 0000000..c9fed89 --- /dev/null +++ b/vpd-backoffice/src/test/java/com/cloudhandson/vpdbackoffice/service/SelectAiFewShotPromptTest.java @@ -0,0 +1,30 @@ +package com.cloudhandson.vpdbackoffice.service; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; +import org.junit.jupiter.api.Test; + +class SelectAiFewShotPromptTest { + + @Test + void includesVerifiedExamplesBeforeTheOriginalQuestion() { + String prompt = SelectAiService.composeFewShotPrompt( + "current active users", + List.of(new QaVectorService.VectorExample( + 7L, + "active users by game", + "SELECT COUNT(*) AS AU_COUNT FROM APP_USER", + "AU count", + "cohere.embed-v4.0", + 0.01 + )) + ); + + assertThat(prompt) + .contains("Verified few-shot examples") + .contains("SELECT COUNT(*) AS AU_COUNT FROM APP_USER") + .contains("Original user question:\ncurrent active users") + .contains("current approved object list and policy"); + } +}