refs #731: enrich Text2SQL prompts with QA examples
This commit is contained in:
@@ -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) {}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user