650 lines
28 KiB
Java
650 lines
28 KiB
Java
package com.cloudhandson.vpdbackoffice.service;
|
|
|
|
import com.cloudhandson.vpdbackoffice.config.BackofficeProperties;
|
|
import com.cloudhandson.vpdbackoffice.domain.token.BearerTokenRecord;
|
|
import com.fasterxml.jackson.core.JsonProcessingException;
|
|
import com.fasterxml.jackson.databind.JsonNode;
|
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
|
import com.fasterxml.jackson.databind.node.ArrayNode;
|
|
import com.fasterxml.jackson.databind.node.ObjectNode;
|
|
import java.math.BigDecimal;
|
|
import java.math.BigInteger;
|
|
import java.sql.Connection;
|
|
import java.sql.DriverManager;
|
|
import java.sql.PreparedStatement;
|
|
import java.sql.ResultSet;
|
|
import java.sql.ResultSetMetaData;
|
|
import java.sql.Statement;
|
|
import java.time.Clock;
|
|
import java.time.LocalDateTime;
|
|
import java.time.ZoneId;
|
|
import java.util.LinkedHashSet;
|
|
import java.util.List;
|
|
import java.util.Locale;
|
|
import java.util.Set;
|
|
import java.util.regex.Pattern;
|
|
import org.springframework.stereotype.Service;
|
|
|
|
/**
|
|
* Generates and executes bounded read-only SQL through the configured schema-owned Select AI profile.
|
|
*/
|
|
@Service
|
|
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 String POLICY_PREFIX =
|
|
"Answer the original user question using the current approved object list and profile policy.\n\n";
|
|
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|"
|
|
+ "dbms_[a-z0-9_]*|utl_[a-z0-9_]*|sys\\s*\\.)\\b"
|
|
);
|
|
|
|
private final BackofficeProperties properties;
|
|
private final BearerTokenService bearerTokenService;
|
|
private final Clock clock;
|
|
private final ObjectMapper objectMapper;
|
|
private final QaVectorService qaVectorService;
|
|
private final GameScopeService gameScopeService;
|
|
private final GameCatalogVectorService gameCatalogVectorService;
|
|
|
|
public SelectAiService(
|
|
BackofficeProperties properties,
|
|
BearerTokenService bearerTokenService,
|
|
Clock clock,
|
|
ObjectMapper objectMapper,
|
|
QaVectorService qaVectorService,
|
|
GameScopeService gameScopeService,
|
|
GameCatalogVectorService gameCatalogVectorService
|
|
) {
|
|
this.properties = properties;
|
|
this.bearerTokenService = bearerTokenService;
|
|
this.clock = clock;
|
|
this.objectMapper = objectMapper;
|
|
this.qaVectorService = qaVectorService;
|
|
this.gameScopeService = gameScopeService;
|
|
this.gameCatalogVectorService = gameCatalogVectorService;
|
|
}
|
|
|
|
public JsonNode generateAndExecute(String bearerToken, String prompt) {
|
|
return generateAndExecute(bearerToken, prompt, null);
|
|
}
|
|
|
|
/**
|
|
* Executes one DB-resolved game scope. The optional key is revalidated for
|
|
* the original question; it is never a caller-provided table or prefix.
|
|
*/
|
|
public JsonNode generateAndExecute(String bearerToken, String prompt, String scopeGameKey) {
|
|
requireActiveToken(bearerToken);
|
|
String normalizedPrompt = requiredPrompt(prompt);
|
|
BackofficeProperties.SelectAi selectAi = requiredSelectAi();
|
|
ResolvedExecutionScope scope = resolveExecutionScope(bearerToken, normalizedPrompt, scopeGameKey);
|
|
|
|
GameContext gameContext = resolveGameContext(bearerToken, normalizedPrompt);
|
|
return generateAndExecutePrepared(
|
|
bearerToken,
|
|
normalizedPrompt,
|
|
selectAi,
|
|
gameContext.prompt(scope.prompt()),
|
|
gameContext.scopeType(),
|
|
gameContext.status(),
|
|
gameContext.candidates().size(),
|
|
scope,
|
|
null,
|
|
null
|
|
);
|
|
}
|
|
|
|
public JsonNode generateAndExecute(
|
|
String bearerToken, String prompt, String scopeGameKey, JsonNode priorToolContext) {
|
|
if (priorToolContext == null || priorToolContext.isMissingNode()
|
|
|| priorToolContext.isNull()) {
|
|
return generateAndExecute(bearerToken, prompt, scopeGameKey);
|
|
}
|
|
requireActiveToken(bearerToken);
|
|
String normalizedPrompt = requiredPrompt(prompt);
|
|
BackofficeProperties.SelectAi selectAi = requiredSelectAi();
|
|
QueryPlanContext queryPlan = requiredQueryPlan(priorToolContext);
|
|
ResolvedExecutionScope scope = queryPlan.scope(scopeGameKey);
|
|
return generateAndExecutePrepared(
|
|
bearerToken,
|
|
normalizedPrompt,
|
|
selectAi,
|
|
queryPlan.prompt(normalizedPrompt),
|
|
queryPlan.targetType(),
|
|
queryPlan.status(),
|
|
queryPlan.targetCount(),
|
|
scope,
|
|
queryPlan.allowedPrefixes(),
|
|
queryPlan
|
|
);
|
|
}
|
|
|
|
private JsonNode generateAndExecutePrepared(
|
|
String bearerToken,
|
|
String originalPrompt,
|
|
BackofficeProperties.SelectAi selectAi,
|
|
String executionPrompt,
|
|
String gameScopeType,
|
|
String gameScopeStatus,
|
|
int gameCandidateCount,
|
|
ResolvedExecutionScope scope,
|
|
Set<String> allowedPrefixes,
|
|
QueryPlanContext queryPlan
|
|
) {
|
|
EnrichedPrompt enrichedPrompt = enrichWithFewShot(
|
|
bearerToken, selectAi, executionPrompt, queryPlan == null ? "ANY" : queryPlan.targetType());
|
|
String generatedSql = generate(selectAi, enrichedPrompt.prompt(), "showsql");
|
|
String normalizedSql = validateReadOnlySql(generatedSql);
|
|
if (allowedPrefixes != null && gameScopeService != null
|
|
&& gameScopeService.configured()
|
|
&& gameScopeService.referencesGameScopedObjectOutsidePrefixes(
|
|
bearerToken, normalizedSql, allowedPrefixes)) {
|
|
// This is a model-output validation failure, not an answer fallback.
|
|
// Give Select AI its own violated-plan feedback once and validate the
|
|
// regenerated SQL under exactly the same read-only and scope rules.
|
|
generatedSql = generate(selectAi, scopeCorrectionPrompt(enrichedPrompt.prompt()), "showsql");
|
|
normalizedSql = validateReadOnlySql(generatedSql);
|
|
if (gameScopeService.referencesGameScopedObjectOutsidePrefixes(
|
|
bearerToken, normalizedSql, allowedPrefixes)) {
|
|
throw new AppException(
|
|
"Select AI 생성 SQL이 게임 질의 계획에 없는 prefix 전용 객체를 참조했습니다.");
|
|
}
|
|
}
|
|
QueryExecution execution = executeReadOnly(selectAi, normalizedSql);
|
|
ObjectNode response = objectMapper.createObjectNode();
|
|
response.put("status", "SHOWSQL_AND_EXECUTED");
|
|
response.put("profile", selectAi.profile());
|
|
response.put("originalPrompt", originalPrompt);
|
|
response.put("gameScopeType", gameScopeType);
|
|
response.put("gameScopeStatus", gameScopeStatus);
|
|
response.put("gameCandidateCount", gameCandidateCount);
|
|
if (queryPlan != null) {
|
|
response.put("queryPlanTargetType", queryPlan.targetType());
|
|
response.put("queryPlanStatus", queryPlan.status());
|
|
response.put("queryPlanTargetCount", queryPlan.targetCount());
|
|
}
|
|
if (scope.gameKey() != null) {
|
|
response.put("scopeGameKey", scope.gameKey());
|
|
response.put("scopeDisplayName", scope.displayName());
|
|
response.put("scopeStatus", queryPlan == null
|
|
? "DB_REVALIDATED" : "QUERY_PLAN_VALIDATED");
|
|
}
|
|
response.put("fewShotStatus", enrichedPrompt.status());
|
|
response.put("fewShotExampleCount", enrichedPrompt.exampleCount());
|
|
addFewShotExamples(response, enrichedPrompt.examples());
|
|
response.put("generatedSql", normalizedSql);
|
|
response.put("execution", "READ_ONLY_EXECUTED");
|
|
response.put("rowCount", execution.items().size());
|
|
response.put("truncated", execution.truncated());
|
|
response.set("items", execution.items());
|
|
response.put("nextStep", execution.truncated()
|
|
? "최초 " + MAX_RESULT_ROWS + "건만 반환했습니다. 생성 SQL로 전체 결과를 확인할 수 있습니다."
|
|
: "생성 SQL을 읽기 전용으로 실행한 결과입니다.");
|
|
return response;
|
|
}
|
|
|
|
private QueryPlanContext requiredQueryPlan(JsonNode plan) {
|
|
JsonNode normalizedPlan = unwrapMcpToolResult(plan);
|
|
String targetType = normalizedPlan.path("targetType")
|
|
.asText("").trim().toUpperCase(Locale.ROOT);
|
|
if (!Set.of("NONE", "SINGLE", "MULTI", "ALL").contains(targetType)
|
|
|| !normalizedPlan.path("targets").isArray()) {
|
|
throw new AppException(
|
|
"queryPlan은 targetType(NONE/SINGLE/MULTI/ALL)과 targets 배열이 필요합니다.");
|
|
}
|
|
Set<String> allowedPrefixes = new LinkedHashSet<>();
|
|
for (JsonNode target : normalizedPlan.path("targets")) {
|
|
String prefix = target.path("gamePrefix").asText("").trim();
|
|
if (!prefix.isEmpty()) {
|
|
allowedPrefixes.add(prefix.toUpperCase(Locale.ROOT));
|
|
}
|
|
}
|
|
return new QueryPlanContext(
|
|
targetType,
|
|
normalizedPlan.path("status").asText(""),
|
|
normalizedPlan.path("targets").size(),
|
|
Set.copyOf(allowedPrefixes),
|
|
normalizedPlan.deepCopy()
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Accept the direct game-query-plan contract as well as the standard MCP
|
|
* tools/call envelope the ReAct model can copy from a preceding observation.
|
|
* This is transport normalization only; the target contract itself remains
|
|
* the authoritative data-driven policy.
|
|
*/
|
|
JsonNode unwrapMcpToolResult(JsonNode candidate) {
|
|
JsonNode current = candidate;
|
|
for (int depth = 0; depth < 6 && current != null; depth++) {
|
|
if (current.isTextual()) {
|
|
try {
|
|
current = objectMapper.readTree(current.asText());
|
|
continue;
|
|
} catch (JsonProcessingException ignored) {
|
|
return current;
|
|
}
|
|
}
|
|
// LangChain can forward an MCP TextContent item verbatim as
|
|
// {"type":"text","text":"{...tools/call response...}"}. Unwrap the
|
|
// transport envelope before looking for the response payload.
|
|
if (current.isObject() && "text".equals(current.path("type").asText())
|
|
&& current.path("text").isTextual()) {
|
|
current = current.path("text");
|
|
continue;
|
|
}
|
|
JsonNode response = current.path("response");
|
|
if (response.isObject() || response.isTextual()) {
|
|
current = response;
|
|
continue;
|
|
}
|
|
break;
|
|
}
|
|
return current == null ? objectMapper.createObjectNode() : current;
|
|
}
|
|
|
|
private GameContext resolveGameContext(String bearerToken, String question) {
|
|
try {
|
|
List<GameCatalogVectorService.GameCandidate> candidates =
|
|
gameCatalogVectorService.search(bearerToken, question, 5);
|
|
String status = candidates.isEmpty() ? "NO_MATCH" : "RESOLVED";
|
|
String scopeType = candidates.isEmpty() ? "UNKNOWN" : "SINGLE_GAME";
|
|
return new GameContext(scopeType, status, candidates);
|
|
} catch (Exception ignored) {
|
|
return new GameContext("UNKNOWN", "UNAVAILABLE", List.of());
|
|
}
|
|
}
|
|
|
|
private ResolvedExecutionScope resolveExecutionScope(
|
|
String bearerToken, String originalPrompt, String scopeGameKey
|
|
) {
|
|
String requestedKey = scopeGameKey == null ? "" : scopeGameKey.trim();
|
|
if (requestedKey.isEmpty()) {
|
|
return new ResolvedExecutionScope(originalPrompt, null, null);
|
|
}
|
|
if (gameScopeService == null) {
|
|
throw new AppException("게임 범위 검증 서비스를 사용할 수 없습니다.");
|
|
}
|
|
GameScopeService.GameScope scope = gameScopeService.resolve(bearerToken, originalPrompt).scopes().stream()
|
|
.filter(item -> requestedKey.equals(item.gameKey()))
|
|
.findFirst()
|
|
.orElseThrow(() -> new AppException("요청한 게임 범위가 DB 조회 결과에 없습니다."));
|
|
if (!"SUPPORTED".equals(scope.status())) {
|
|
throw new AppException("DB 게임 범위가 조회 실행을 허용하지 않습니다: " + scope.reasonCode());
|
|
}
|
|
String scopedPrompt = "Use only the DB-resolved game scope below. Do not generate SQL for any other "
|
|
+ "game mentioned in the original question. The scope was validated through current game alias "
|
|
+ "metadata and approved object availability.\n"
|
|
+ "Resolved game key: " + scope.gameKey() + "\n"
|
|
+ "Resolved display name: " + scope.displayName() + "\n"
|
|
+ "Matched alias: " + scope.matchedAlias() + "\n\n"
|
|
+ "Original user question:\n" + originalPrompt;
|
|
return new ResolvedExecutionScope(scopedPrompt, scope.gameKey(), scope.displayName());
|
|
}
|
|
|
|
private void addFewShotExamples(ObjectNode response, List<QaVectorService.VectorExample> examples) {
|
|
ArrayNode items = response.putArray("fewShotExamples");
|
|
for (QaVectorService.VectorExample example : examples) {
|
|
ObjectNode item = items.addObject();
|
|
item.put("exampleId", example.exampleId());
|
|
item.put("question", example.question());
|
|
item.put("answerSql", example.answerSql());
|
|
if (example.answer() != null) {
|
|
item.put("answer", example.answer());
|
|
}
|
|
item.put("embeddingModel", example.embeddingModel());
|
|
item.put("referenceKind", example.referenceKind());
|
|
item.put("targetType", example.targetType());
|
|
if (example.objectRole() != null) {
|
|
item.put("objectRole", example.objectRole());
|
|
}
|
|
if (example.sourceCaseId() != null) {
|
|
item.put("sourceCaseId", example.sourceCaseId());
|
|
}
|
|
if (example.sourceType() != null) {
|
|
item.put("sourceType", example.sourceType());
|
|
}
|
|
item.put("cosineDistance", example.cosineDistance());
|
|
}
|
|
}
|
|
|
|
/** Returns the prompt Select AI assembled for SQL generation without executing generated SQL. */
|
|
public JsonNode generatePrompt(String bearerToken, String prompt) {
|
|
requireActiveToken(bearerToken);
|
|
String normalizedPrompt = requiredPrompt(prompt);
|
|
BackofficeProperties.SelectAi selectAi = requiredSelectAi();
|
|
EnrichedPrompt enrichedPrompt = enrichWithFewShot(bearerToken, selectAi, normalizedPrompt, "ANY");
|
|
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;
|
|
}
|
|
|
|
private BackofficeProperties.SelectAi requiredSelectAi() {
|
|
BackofficeProperties.SelectAi selectAi = properties == null ? null : properties.selectAi();
|
|
if (selectAi == null || !selectAi.configured()) {
|
|
throw new AppException("Select AI 연결 설정이 필요합니다. "
|
|
+ "BACKOFFICE_SELECT_AI_DB_URL, BACKOFFICE_SELECT_AI_DB_USERNAME, "
|
|
+ "BACKOFFICE_SELECT_AI_DB_PASSWORD를 확인하세요.");
|
|
}
|
|
return selectAi;
|
|
}
|
|
|
|
private void requireActiveToken(String bearerToken) {
|
|
if (bearerToken == null || bearerToken.isBlank()) {
|
|
throw new VpdTokenAccessDeniedException();
|
|
}
|
|
BearerTokenRecord token = bearerTokenService.findByPlainToken(bearerToken.trim());
|
|
LocalDateTime now = LocalDateTime.now(clock.withZone(ZoneId.systemDefault()));
|
|
if (token == null || !token.active(now)) {
|
|
throw new VpdTokenAccessDeniedException();
|
|
}
|
|
}
|
|
|
|
private String requiredPrompt(String prompt) {
|
|
String normalized = prompt == null ? "" : prompt.trim();
|
|
if (normalized.isEmpty()) {
|
|
throw new AppException("prompt는 필수입니다.");
|
|
}
|
|
if (normalized.length() > MAX_PROMPT_LENGTH) {
|
|
throw new AppException("prompt는 " + MAX_PROMPT_LENGTH + "자 이하여야 합니다.");
|
|
}
|
|
return normalized;
|
|
}
|
|
|
|
private String generate(
|
|
BackofficeProperties.SelectAi selectAi,
|
|
String prompt,
|
|
String action
|
|
) {
|
|
String sql = "SELECT DBMS_CLOUD_AI.GENERATE(?, ?, ?) FROM dual";
|
|
try (Connection connection = DriverManager.getConnection(
|
|
selectAi.dbUrl(), selectAi.dbUsername(), selectAi.dbPassword());
|
|
PreparedStatement statement = connection.prepareStatement(sql)) {
|
|
statement.setString(1, prompt);
|
|
statement.setString(2, selectAi.profile());
|
|
statement.setString(3, action);
|
|
try (ResultSet resultSet = statement.executeQuery()) {
|
|
if (!resultSet.next() || resultSet.getString(1) == null) {
|
|
throw new AppException("Select AI가 " + action.toUpperCase() + " 결과를 반환하지 않았습니다.");
|
|
}
|
|
return resultSet.getString(1);
|
|
}
|
|
} catch (AppException exception) {
|
|
throw exception;
|
|
} catch (Exception exception) {
|
|
throw new AppException("Select AI " + action.toUpperCase() + " 생성 실패: "
|
|
+ exception.getMessage());
|
|
}
|
|
}
|
|
|
|
private EnrichedPrompt enrichWithFewShot(
|
|
String bearerToken,
|
|
BackofficeProperties.SelectAi selectAi,
|
|
String prompt,
|
|
String targetType
|
|
) {
|
|
if (!fewShotEnabled(selectAi) || qaVectorService == null) {
|
|
return new EnrichedPrompt(prompt, "DISABLED", 0, List.of());
|
|
}
|
|
try {
|
|
List<QaVectorService.VectorExample> examples = qaVectorService
|
|
.search(bearerToken, prompt, fewShotTopK(selectAi), targetType)
|
|
.examples();
|
|
if (examples.isEmpty()) {
|
|
return new EnrichedPrompt(composePolicyPrompt(prompt), "NO_MATCH", 0, List.of());
|
|
}
|
|
return new EnrichedPrompt(
|
|
composeFewShotPrompt(prompt, examples), "APPLIED", Math.min(examples.size(), MAX_FEW_SHOT_EXAMPLES),
|
|
examples.subList(0, 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(composePolicyPrompt(prompt), "UNAVAILABLE", 0, List.of());
|
|
}
|
|
}
|
|
|
|
static String composePolicyPrompt(String prompt) {
|
|
return POLICY_PREFIX
|
|
+ "Resolve business terms and game names from the approved game-alias metadata before selecting a "
|
|
+ "game-scoped object. A generic term such as common user means no particular game. If no game alias "
|
|
+ "is resolved, do not substitute an arbitrary game-scoped object. Keep common-object questions "
|
|
+ "game-neutral and preserve the resolver status for the answer layer.\n"
|
|
+ "Original user question:\n" + prompt;
|
|
}
|
|
|
|
static String scopeCorrectionPrompt(String originalPrompt) {
|
|
return originalPrompt
|
|
+ "\n\n[SQL VALIDATION FEEDBACK]\n"
|
|
+ "The previous SQL selected a game-scoped object outside the authoritative query plan. "
|
|
+ "Regenerate one read-only SQL statement using the same plan. Do not substitute any "
|
|
+ "game-scoped object. Apply the active profile instructions and do not report a SQL failure.\n";
|
|
}
|
|
|
|
static String composeFewShotPrompt(String prompt, List<QaVectorService.VectorExample> examples) {
|
|
StringBuilder enriched = new StringBuilder(POLICY_PREFIX
|
|
+ "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. "
|
|
+ "When examples use a game-specific object, reuse that pattern only after the current question "
|
|
+ "resolves the same game alias; otherwise keep the query unscoped or request clarification.\n\n"
|
|
+ "Verified few-shot examples:\n");
|
|
int included = 0;
|
|
for (QaVectorService.VectorExample example : examples) {
|
|
if (included >= MAX_FEW_SHOT_EXAMPLES) {
|
|
break;
|
|
}
|
|
String candidate = referenceCandidate(included + 1, example);
|
|
if (candidate.isBlank()) {
|
|
continue;
|
|
}
|
|
if (enriched.length() + candidate.length() + prompt.length() > MAX_ENRICHED_PROMPT_LENGTH) {
|
|
break;
|
|
}
|
|
enriched.append(candidate);
|
|
included++;
|
|
}
|
|
if (included == 0) {
|
|
return composePolicyPrompt(prompt);
|
|
}
|
|
return enriched.append("Original user question:\n").append(prompt).toString();
|
|
}
|
|
|
|
private static String referenceCandidate(int index, QaVectorService.VectorExample example) {
|
|
String prefix = "Example " + index + " question:\n" + example.question()
|
|
+ "\nExample " + index + " target type: " + example.targetType()
|
|
+ "\nExample " + index + " reference kind: " + example.referenceKind()
|
|
+ "\nExample " + index + " logical object role: "
|
|
+ (example.objectRole() == null || example.objectRole().isBlank()
|
|
? "UNSPECIFIED" : example.objectRole()) + "\n";
|
|
if ("NO_TARGET".equals(example.referenceKind())
|
|
|| "OBJECT_UNAVAILABLE".equals(example.referenceKind())) {
|
|
String boundary = truncate(example.answer(), MAX_FEW_SHOT_SQL_CHARS);
|
|
String answerSql = truncate(example.answerSql(), MAX_FEW_SHOT_SQL_CHARS);
|
|
if (boundary.isBlank() || answerSql.isBlank()) {
|
|
return "";
|
|
}
|
|
return prefix + "Boundary rule (when the current plan is NONE and this logical object role "
|
|
+ "matches the requested operation, follow this boundary SQL template instead of "
|
|
+ "substituting a game-scoped object; do not apply it to an approved common-object operation):\n"
|
|
+ boundary
|
|
+ "\nVerified boundary SQL template:\n" + answerSql + "\n\n";
|
|
}
|
|
String answerSql = truncate(example.answerSql(), MAX_FEW_SHOT_SQL_CHARS);
|
|
return answerSql.isBlank() ? "" : prefix + "Verified SQL template:\n" + answerSql + "\n\n";
|
|
}
|
|
|
|
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;
|
|
try (Connection connection = DriverManager.getConnection(
|
|
selectAi.dbUrl(), selectAi.dbUsername(), selectAi.dbPassword());
|
|
Statement transaction = connection.createStatement()) {
|
|
connection.setAutoCommit(false);
|
|
connection.setReadOnly(true);
|
|
transaction.execute("SET TRANSACTION READ ONLY");
|
|
try (PreparedStatement statement = connection.prepareStatement(generatedSql)) {
|
|
statement.setQueryTimeout(QUERY_TIMEOUT_SECONDS);
|
|
statement.setFetchSize(MAX_RESULT_ROWS + 1);
|
|
statement.setMaxRows(MAX_RESULT_ROWS + 1);
|
|
try (ResultSet resultSet = statement.executeQuery()) {
|
|
ResultSetMetaData metadata = resultSet.getMetaData();
|
|
while (resultSet.next()) {
|
|
if (items.size() >= MAX_RESULT_ROWS) {
|
|
truncated = true;
|
|
break;
|
|
}
|
|
ObjectNode row = items.addObject();
|
|
for (int columnIndex = 1; columnIndex <= metadata.getColumnCount(); columnIndex++) {
|
|
String column = metadata.getColumnLabel(columnIndex);
|
|
if (column == null || column.isBlank()) {
|
|
column = metadata.getColumnName(columnIndex);
|
|
}
|
|
putResultValue(row, column, resultSet.getObject(columnIndex));
|
|
}
|
|
}
|
|
}
|
|
} finally {
|
|
connection.rollback();
|
|
}
|
|
} catch (Exception exception) {
|
|
throw new AppException("Select AI 생성 SQL 실행 실패: " + exception.getMessage());
|
|
}
|
|
return new QueryExecution(items, truncated);
|
|
}
|
|
|
|
private void putResultValue(ObjectNode row, String column, Object value) {
|
|
if (value == null) {
|
|
row.putNull(column);
|
|
} else if (value instanceof BigDecimal number) {
|
|
row.put(column, number);
|
|
} else if (value instanceof BigInteger number) {
|
|
row.put(column, number);
|
|
} else if (value instanceof Integer number) {
|
|
row.put(column, number);
|
|
} else if (value instanceof Long number) {
|
|
row.put(column, number);
|
|
} else if (value instanceof Short number) {
|
|
row.put(column, number);
|
|
} else if (value instanceof Float number) {
|
|
row.put(column, number);
|
|
} else if (value instanceof Double number) {
|
|
row.put(column, number);
|
|
} else if (value instanceof Boolean bool) {
|
|
row.put(column, bool);
|
|
} else {
|
|
row.put(column, String.valueOf(value));
|
|
}
|
|
}
|
|
|
|
private String validateReadOnlySql(String generatedSql) {
|
|
String normalized = generatedSql == null ? "" : generatedSql.trim();
|
|
if (normalized.startsWith("```")) {
|
|
int firstLineEnd = normalized.indexOf('\n');
|
|
int closingFence = normalized.lastIndexOf("```");
|
|
if (firstLineEnd >= 0 && closingFence > firstLineEnd) {
|
|
normalized = normalized.substring(firstLineEnd + 1, closingFence).trim();
|
|
}
|
|
}
|
|
normalized = normalized.replaceFirst(";\\s*$", "").trim();
|
|
if (!normalized.matches("(?is)^(select|with)\\b.*")) {
|
|
throw new AppException("Select AI가 읽기 전용 SELECT/WITH SQL을 반환하지 않았습니다.");
|
|
}
|
|
if (normalized.contains(";")) {
|
|
throw new AppException("Select AI 결과에 여러 SQL 문장이 포함되어 있어 반환하지 않습니다.");
|
|
}
|
|
if (normalized.contains("--") || normalized.contains("/*") || normalized.contains("*/")
|
|
|| UNSAFE_SQL.matcher(normalized).find()) {
|
|
throw new AppException("Select AI 결과에 실행이 허용되지 않는 SQL 구문이 포함되어 있습니다.");
|
|
}
|
|
return normalized;
|
|
}
|
|
|
|
private record QueryExecution(ArrayNode items, boolean truncated) {}
|
|
|
|
private record EnrichedPrompt(
|
|
String prompt, String status, int exampleCount, List<QaVectorService.VectorExample> examples) {}
|
|
|
|
private record ResolvedExecutionScope(String prompt, String gameKey, String displayName) {}
|
|
|
|
private record QueryPlanContext(
|
|
String targetType,
|
|
String status,
|
|
int targetCount,
|
|
Set<String> allowedPrefixes,
|
|
JsonNode plan
|
|
) {
|
|
String prompt(String originalQuestion) {
|
|
return "[AUTHORITATIVE GAME QUERY PLAN]\n" + plan
|
|
+ "\n[ORIGINAL USER QUESTION]\n" + originalQuestion;
|
|
}
|
|
|
|
ResolvedExecutionScope scope(String requestedGameKey) {
|
|
String requested = requestedGameKey == null ? "" : requestedGameKey.trim();
|
|
if (requested.isEmpty()) {
|
|
return new ResolvedExecutionScope("", null, null);
|
|
}
|
|
for (JsonNode target : plan.path("targets")) {
|
|
if (requested.equals(target.path("gameKey").asText(""))) {
|
|
return new ResolvedExecutionScope(
|
|
"", requested, target.path("gameName").asText(requested));
|
|
}
|
|
}
|
|
throw new AppException("scopeGameKey가 queryPlan targets에 없습니다.");
|
|
}
|
|
}
|
|
|
|
private record GameContext(
|
|
String scopeType, String status, List<GameCatalogVectorService.GameCandidate> candidates) {
|
|
String prompt(String original) {
|
|
StringBuilder context = new StringBuilder();
|
|
context.append("[GAME CATALOG MATCH]\n")
|
|
.append("scope_type: ").append(scopeType).append('\n')
|
|
.append("status: ").append(status).append('\n');
|
|
for (GameCatalogVectorService.GameCandidate candidate : candidates) {
|
|
context.append("game_key: ").append(candidate.gameKey()).append('\n')
|
|
.append("game_id: ").append(candidate.gameId()).append('\n')
|
|
.append("game_prefix: ").append(candidate.gamePrefix()).append('\n')
|
|
.append("game_name: ").append(candidate.gameName()).append('\n')
|
|
.append("matched_aliases: ").append(candidate.aliases()).append('\n')
|
|
.append("user_master_object_name: ")
|
|
.append(candidate.userMasterObjectName()).append('\n')
|
|
.append("cosine_distance: ").append(candidate.similarity()).append('\n');
|
|
}
|
|
context.append("[GAME SCOPE METADATA]\n")
|
|
.append("The following resolver facts are authoritative metadata. ")
|
|
.append("Apply the table and column annotations associated with these facts; ")
|
|
.append("do not invent identifiers or scope rules.\n\n")
|
|
.append(original);
|
|
return context.toString();
|
|
}
|
|
}
|
|
}
|