refs #731: enrich Text2SQL prompts with QA examples
This commit is contained in:
@@ -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 배열입니다.
|
||||
# 배포 환경마다 반드시 실제 소유자와 허용 객체를 지정합니다.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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) {}
|
||||
}
|
||||
|
||||
@@ -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:}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user