Files
vpd-permission-poc/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/SelectAiService.java

647 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();
// Game identity is resolved only in ADB by sg_game_query_plan. That
// function invokes OCI GenAI chat and validates its candidate choice
// against the database catalog; Java never infers aliases or tables.
QueryPlanContext queryPlan = requiredQueryPlan(
gameCatalogVectorService.queryPlan(bearerToken, normalizedPrompt, 5));
ResolvedExecutionScope scope = queryPlan.scope(scopeGameKey);
return generateAndExecutePrepared(
bearerToken,
normalizedPrompt,
selectAi,
queryPlan.prompt(normalizedPrompt, scope.gameKey()),
queryPlan.targetType(),
queryPlan.status(),
queryPlan.targetCount(),
scope,
queryPlan.allowedPrefixes(),
queryPlan,
List.of()
);
}
public JsonNode generateAndExecute(
String bearerToken, String prompt, String scopeGameKey, JsonNode priorToolContext) {
return generateAndExecute(bearerToken, prompt, scopeGameKey, priorToolContext, null);
}
/**
* Uses only rehydrated approved pattern ids from the preceding preflight.
* The preflight runs before the OCI Chat game plan; the plan still controls
* the final target-type compatibility and all game identity.
*/
public JsonNode generateAndExecute(
String bearerToken, String prompt, String scopeGameKey, JsonNode priorToolContext,
JsonNode fewShotPreflight) {
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);
List<QaVectorService.VectorExample> preflightExamples = approvedPreflightExamples(
bearerToken, fewShotPreflight, queryPlan.targetType());
return generateAndExecutePrepared(
bearerToken,
normalizedPrompt,
selectAi,
queryPlan.prompt(normalizedPrompt, scope.gameKey()),
queryPlan.targetType(),
queryPlan.status(),
queryPlan.targetCount(),
scope,
queryPlan.allowedPrefixes(),
queryPlan,
preflightExamples
);
}
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,
List<QaVectorService.VectorExample> preflightExamples
) {
EnrichedPrompt enrichedPrompt = enrichWithFewShot(
bearerToken, selectAi, originalPrompt, executionPrompt,
queryPlan == null ? "ANY" : queryPlan.targetType(), preflightExamples);
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", 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());
response.put("selectAiReference", queryPlan.selectAiReference());
}
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 List<QaVectorService.VectorExample> approvedPreflightExamples(
String bearerToken, JsonNode fewShotPreflight, String targetType) {
if (fewShotPreflight == null || fewShotPreflight.isNull() || fewShotPreflight.isMissingNode()
|| qaVectorService == null) {
return List.of();
}
JsonNode preflight = unwrapMcpToolResult(fewShotPreflight);
if (!"FEWSHOT_PREFLIGHT".equals(preflight.path("status").asText())) {
return List.of();
}
List<Long> ids = new java.util.ArrayList<>();
for (JsonNode example : preflight.path("examples")) {
if (example.path("exampleId").canConvertToLong()) {
ids.add(example.path("exampleId").asLong());
}
}
return qaVectorService.findApprovedExamples(bearerToken, ids, targetType);
}
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("gameTargets").isArray()
|| normalizedPlan.path("selectAiReference").asText("").isBlank()) {
throw new AppException(
"queryPlan은 targetType, gameTargets, selectAiReference가 필요합니다.");
}
Set<String> allowedPrefixes = new LinkedHashSet<>();
for (JsonNode target : normalizedPlan.path("gameTargets")) {
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("gameTargets").size(),
Set.copyOf(allowedPrefixes),
normalizedPlan.path("selectAiReference").asText().trim(),
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 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, normalizedPrompt, "ANY", List.of());
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 retrievalQuestion, String generationPrompt, String targetType,
List<QaVectorService.VectorExample> preflightExamples) {
if (!fewShotEnabled(selectAi) || qaVectorService == null) {
return new EnrichedPrompt(generationPrompt, "DISABLED", 0, List.of());
}
try {
List<QaVectorService.VectorExample> examples = preflightExamples == null || preflightExamples.isEmpty()
? qaVectorService.search(bearerToken, retrievalQuestion, fewShotTopK(selectAi), targetType).examples()
: preflightExamples;
if (examples.isEmpty()) {
return new EnrichedPrompt(composePolicyPrompt(generationPrompt), "NO_MATCH", 0, List.of());
}
return new EnrichedPrompt(
composeFewShotPrompt(generationPrompt, 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(generationPrompt), "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 a game-specific object. Follow the authoritative scope guidance "
+ "and preserve the resolver status for the answer layer.\n"
+ "Original user question:\n" + prompt;
}
static String composeFewShotPrompt(String prompt, List<QaVectorService.VectorExample> examples) {
StringBuilder enriched = new StringBuilder(POLICY_PREFIX
+ "Reference precedence:\n"
+ "1. A [REQUIRED BOUNDARY REFERENCE] is a verified decision reference for its matching "
+ "target type and logical object role. You must apply its boundary decision before generating SQL. "
+ "Do not replace it with a game-scoped object.\n"
+ "2. Normal SQL-pattern examples are required result-shape references when their logical object role "
+ "and requested result grain match the original question. Preserve the matching aggregate versus "
+ "individual-detail shape; do not replace an aggregate example with detail rows, or the reverse, "
+ "unless the user explicitly asks for that different shape. Do not invent "
+ "identifiers, and do not override current metadata or game-alias resolution policy. "
+ "When an example uses 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 references:\n");
List<QaVectorService.VectorExample> ordered = new java.util.ArrayList<>(examples.size());
for (QaVectorService.VectorExample example : examples) {
if (isBoundaryReference(example)) {
ordered.add(example);
}
}
for (QaVectorService.VectorExample example : examples) {
if (!isBoundaryReference(example)) {
ordered.add(example);
}
}
int included = 0;
for (QaVectorService.VectorExample example : ordered) {
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 (isBoundaryReference(example)) {
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 "[REQUIRED BOUNDARY REFERENCE]\n" + prefix
+ "This verified boundary reference must be applied when the current target type and logical "
+ "object role match. Use 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);
String answerGuide = truncate(example.answer(), MAX_FEW_SHOT_SQL_CHARS);
if (answerSql.isBlank()) {
return "";
}
return prefix + "Verified SQL template:\n" + answerSql
+ (answerGuide.isBlank() ? "" : "\nExpected answer guidance:\n" + answerGuide)
+ "\n\n";
}
private static boolean isBoundaryReference(QaVectorService.VectorExample example) {
return "NO_TARGET".equals(example.referenceKind())
|| "OBJECT_UNAVAILABLE".equals(example.referenceKind());
}
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,
String selectAiReference,
JsonNode plan
) {
String prompt(String originalQuestion, String scopeGameKey) {
if (scopeGameKey == null || scopeGameKey.isBlank()) {
return selectAiReference + "\n\n[ORIGINAL USER QUESTION]\n" + originalQuestion;
}
if (plan.path("gameTargets").size() != 1) {
throw new AppException("Worker queryPlan must contain exactly one target.");
}
JsonNode target = plan.path("gameTargets").get(0);
if (!scopeGameKey.equals(target.path("gameKey").asText(""))) {
throw new AppException("scopeGameKey가 worker queryPlan 대상과 일치하지 않습니다.");
}
return selectAiReference + "\n\n[TASK QUESTION]\n" + originalQuestion;
}
ResolvedExecutionScope scope(String requestedGameKey) {
String requested = requestedGameKey == null ? "" : requestedGameKey.trim();
if (requested.isEmpty()) {
return new ResolvedExecutionScope("", null, null);
}
if (plan.path("gameTargets").size() != 1) {
throw new AppException("Worker queryPlan must contain exactly one target.");
}
for (JsonNode target : plan.path("gameTargets")) {
if (requested.equals(target.path("gameKey").asText(""))) {
return new ResolvedExecutionScope(
"", requested, target.path("gameName").asText(requested));
}
}
throw new AppException("scopeGameKey가 queryPlan targets에 없습니다.");
}
}
}