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