refs #731: enrich Text2SQL prompts with QA examples
This commit is contained in:
@@ -57,6 +57,8 @@ export BACKOFFICE_SELECT_AI_DB_URL="${BACKOFFICE_DB_URL}"
|
|||||||
export BACKOFFICE_SELECT_AI_DB_USERNAME=""
|
export BACKOFFICE_SELECT_AI_DB_USERNAME=""
|
||||||
export BACKOFFICE_SELECT_AI_DB_PASSWORD=""
|
export BACKOFFICE_SELECT_AI_DB_PASSWORD=""
|
||||||
export BACKOFFICE_SELECT_AI_PROFILE=""
|
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 배열입니다.
|
# 공통 데이터 카탈로그. objects는 key/tableName/objectType/businessName/description JSON 배열입니다.
|
||||||
# 배포 환경마다 반드시 실제 소유자와 허용 객체를 지정합니다.
|
# 배포 환경마다 반드시 실제 소유자와 허용 객체를 지정합니다.
|
||||||
|
|||||||
@@ -37,9 +37,13 @@
|
|||||||
3. 기존 Text2SQL 도구로 SQL을 생성·검토·실행한다.
|
3. 기존 Text2SQL 도구로 SQL을 생성·검토·실행한다.
|
||||||
4. 검토 통과한 질문·생성 SQL·필요 시 답변을 `qa_vector_store`로 저장한다.
|
4. 검토 통과한 질문·생성 SQL·필요 시 답변을 `qa_vector_store`로 저장한다.
|
||||||
|
|
||||||
현재 MCP는 검색 결과를 반환한다. Text2SQL tool의 내부 Select AI prompt에
|
Text2SQL은 `BACKOFFICE_SELECT_AI_FEW_SHOT_ENABLED`가 true일 때 검색 결과의
|
||||||
자동 주입하는 변경은 별도 단계로 두어, 검색 결과와 실제 prompt 구성을
|
상위 `BACKOFFICE_SELECT_AI_FEW_SHOT_TOP_K`개(기본 3, 최대 3)를 내부 프롬프트에
|
||||||
운영자가 먼저 확인할 수 있게 한다.
|
자동 보강한다. 예제는 현재 object list·게임 별칭 해석·정책을 대체하지 않으며,
|
||||||
|
보강 실패 또는 일치 예제 없음은 기존 Text2SQL 경로를 중단시키지 않는다.
|
||||||
|
|
||||||
|
고객 질문 재평가에서 FAIL이 확인되면 기준 SQL을 검토한 뒤에만 저장하고, 같은
|
||||||
|
질문을 다시 실행해 `fewShotStatus=APPLIED` 및 판정 개선 여부를 기록한다.
|
||||||
|
|
||||||
## Smilegate 포털 allowlist
|
## Smilegate 포털 allowlist
|
||||||
|
|
||||||
|
|||||||
@@ -79,9 +79,15 @@ public record BackofficeProperties(
|
|||||||
String dbUrl,
|
String dbUrl,
|
||||||
String dbUsername,
|
String dbUsername,
|
||||||
String dbPassword,
|
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() {
|
public boolean configured() {
|
||||||
return dbUrl != null && !dbUrl.isBlank()
|
return dbUrl != null && !dbUrl.isBlank()
|
||||||
&& dbUsername != null && !dbUsername.isBlank()
|
&& dbUsername != null && !dbUsername.isBlank()
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import java.sql.Statement;
|
|||||||
import java.time.Clock;
|
import java.time.Clock;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.time.ZoneId;
|
import java.time.ZoneId;
|
||||||
|
import java.util.List;
|
||||||
import java.util.regex.Pattern;
|
import java.util.regex.Pattern;
|
||||||
import org.springframework.stereotype.Service;
|
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_PROMPT_LENGTH = 4_000;
|
||||||
private static final int MAX_RESULT_ROWS = 100;
|
private static final int MAX_RESULT_ROWS = 100;
|
||||||
private static final int QUERY_TIMEOUT_SECONDS = 30;
|
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(
|
private static final Pattern UNSAFE_SQL = Pattern.compile(
|
||||||
"(?is)\\b(?:insert|update|delete|merge|alter|drop|create|truncate|grant|revoke|"
|
"(?is)\\b(?:insert|update|delete|merge|alter|drop|create|truncate|grant|revoke|"
|
||||||
+ "commit|rollback|savepoint|lock|call|exec(?:ute)?|begin|declare|for\\s+update|"
|
+ "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 BearerTokenService bearerTokenService;
|
||||||
private final Clock clock;
|
private final Clock clock;
|
||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
|
private final QaVectorService qaVectorService;
|
||||||
|
|
||||||
public SelectAiService(
|
public SelectAiService(
|
||||||
BackofficeProperties properties,
|
BackofficeProperties properties,
|
||||||
BearerTokenService bearerTokenService,
|
BearerTokenService bearerTokenService,
|
||||||
Clock clock,
|
Clock clock,
|
||||||
ObjectMapper objectMapper
|
ObjectMapper objectMapper,
|
||||||
|
QaVectorService qaVectorService
|
||||||
) {
|
) {
|
||||||
this.properties = properties;
|
this.properties = properties;
|
||||||
this.bearerTokenService = bearerTokenService;
|
this.bearerTokenService = bearerTokenService;
|
||||||
this.clock = clock;
|
this.clock = clock;
|
||||||
this.objectMapper = objectMapper;
|
this.objectMapper = objectMapper;
|
||||||
|
this.qaVectorService = qaVectorService;
|
||||||
}
|
}
|
||||||
|
|
||||||
public JsonNode generateAndExecute(String bearerToken, String prompt) {
|
public JsonNode generateAndExecute(String bearerToken, String prompt) {
|
||||||
@@ -57,12 +64,16 @@ public class SelectAiService {
|
|||||||
String normalizedPrompt = requiredPrompt(prompt);
|
String normalizedPrompt = requiredPrompt(prompt);
|
||||||
BackofficeProperties.SelectAi selectAi = requiredSelectAi();
|
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);
|
String normalizedSql = validateReadOnlySql(generatedSql);
|
||||||
QueryExecution execution = executeReadOnly(selectAi, normalizedSql);
|
QueryExecution execution = executeReadOnly(selectAi, normalizedSql);
|
||||||
ObjectNode response = objectMapper.createObjectNode();
|
ObjectNode response = objectMapper.createObjectNode();
|
||||||
response.put("status", "SHOWSQL_AND_EXECUTED");
|
response.put("status", "SHOWSQL_AND_EXECUTED");
|
||||||
response.put("profile", selectAi.profile());
|
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("generatedSql", normalizedSql);
|
||||||
response.put("execution", "READ_ONLY_EXECUTED");
|
response.put("execution", "READ_ONLY_EXECUTED");
|
||||||
response.put("rowCount", execution.items().size());
|
response.put("rowCount", execution.items().size());
|
||||||
@@ -79,11 +90,15 @@ public class SelectAiService {
|
|||||||
requireActiveToken(bearerToken);
|
requireActiveToken(bearerToken);
|
||||||
String normalizedPrompt = requiredPrompt(prompt);
|
String normalizedPrompt = requiredPrompt(prompt);
|
||||||
BackofficeProperties.SelectAi selectAi = requiredSelectAi();
|
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();
|
ObjectNode response = objectMapper.createObjectNode();
|
||||||
response.put("status", "SHOWPROMPT");
|
response.put("status", "SHOWPROMPT");
|
||||||
response.put("profile", selectAi.profile());
|
response.put("profile", selectAi.profile());
|
||||||
|
response.put("originalPrompt", normalizedPrompt);
|
||||||
|
response.put("fewShotStatus", enrichedPrompt.status());
|
||||||
|
response.put("fewShotExampleCount", enrichedPrompt.exampleCount());
|
||||||
response.put("selectAiPrompt", selectAiPrompt);
|
response.put("selectAiPrompt", selectAiPrompt);
|
||||||
return response;
|
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) {
|
private QueryExecution executeReadOnly(BackofficeProperties.SelectAi selectAi, String generatedSql) {
|
||||||
ArrayNode items = objectMapper.createArrayNode();
|
ArrayNode items = objectMapper.createArrayNode();
|
||||||
boolean truncated = false;
|
boolean truncated = false;
|
||||||
@@ -233,4 +317,6 @@ public class SelectAiService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private record QueryExecution(ArrayNode items, boolean truncated) {}
|
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-username: ${BACKOFFICE_SELECT_AI_DB_USERNAME:}
|
||||||
db-password: ${BACKOFFICE_SELECT_AI_DB_PASSWORD:}
|
db-password: ${BACKOFFICE_SELECT_AI_DB_PASSWORD:}
|
||||||
profile: ${BACKOFFICE_SELECT_AI_PROFILE:}
|
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:
|
catalog:
|
||||||
owner: ${BACKOFFICE_CATALOG_OWNER:}
|
owner: ${BACKOFFICE_CATALOG_OWNER:}
|
||||||
objects: ${BACKOFFICE_CATALOG_OBJECTS:}
|
objects: ${BACKOFFICE_CATALOG_OBJECTS:}
|
||||||
|
|||||||
@@ -196,7 +196,7 @@ class McpSseServiceTest {
|
|||||||
private boolean showpromptCalled;
|
private boolean showpromptCalled;
|
||||||
|
|
||||||
private CapturingSelectAiService() {
|
private CapturingSelectAiService() {
|
||||||
super(null, null, null, new ObjectMapper());
|
super(null, null, null, new ObjectMapper(), null);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@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