refs #731: enrich Text2SQL prompts with QA examples

This commit is contained in:
devmrko
2026-07-24 15:59:41 +09:00
parent c8fa6af679
commit 3fcd699d92
7 changed files with 138 additions and 8 deletions

View File

@@ -57,6 +57,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 배열입니다.
# 배포 환경마다 반드시 실제 소유자와 허용 객체를 지정합니다.

View File

@@ -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

View File

@@ -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()

View File

@@ -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<QaVectorService.VectorExample> 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<QaVectorService.VectorExample> 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) {}
}

View File

@@ -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:}

View File

@@ -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

View File

@@ -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");
}
}