refs #731: enrich Text2SQL prompts with QA examples

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

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