refs #739: preserve Smilegate changes before repository layout migration

This commit is contained in:
devmrko
2026-08-03 11:12:19 +09:00
parent 022ae7f9d2
commit 4d2964b5c6
58 changed files with 3002 additions and 291 deletions

View File

@@ -0,0 +1,118 @@
package com.cloudhandson.vpdbackoffice.service;
import com.cloudhandson.vpdbackoffice.config.BackofficeProperties;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import java.sql.CallableStatement;
import java.sql.Clob;
import java.sql.Connection;
import java.sql.Date;
import java.sql.DriverManager;
import java.sql.Types;
import java.time.LocalDate;
import org.springframework.stereotype.Service;
/** Transport-only gateway for the catalog-driven deterministic daily-AU function. */
@Service
public class GameDailyAuLookupService {
private final BackofficeProperties properties;
private final BearerTokenService bearerTokenService;
private final ObjectMapper objectMapper;
public GameDailyAuLookupService(
BackofficeProperties properties,
BearerTokenService bearerTokenService,
ObjectMapper objectMapper
) {
this.properties = properties;
this.bearerTokenService = bearerTokenService;
this.objectMapper = objectMapper;
}
public ObjectNode lookup(String token, JsonNode queryPlan, String baseDate) {
requireActiveToken(token);
JsonNode normalizedPlan = unwrapQueryPlan(queryPlan);
if (normalizedPlan == null || normalizedPlan.isMissingNode() || normalizedPlan.isNull()
|| !normalizedPlan.isObject() || !normalizedPlan.hasNonNull("targetType")) {
throw new AppException("game_query_plan 결과가 필요합니다.");
}
Date requestedDate = parseDate(baseDate);
BackofficeProperties.SelectAi database = requiredDatabase();
try (Connection connection = DriverManager.getConnection(
database.dbUrl(), database.dbUsername(), database.dbPassword());
CallableStatement statement = connection.prepareCall(
"{ ? = call sg_game_daily_au_lookup(?, ?) }")) {
statement.registerOutParameter(1, Types.CLOB);
statement.setString(2, objectMapper.writeValueAsString(normalizedPlan));
if (requestedDate == null) {
statement.setNull(3, Types.DATE);
} else {
statement.setDate(3, requestedDate);
}
statement.execute();
Clob value = (Clob) statement.getObject(1);
String raw = value == null ? "" : value.getSubString(1, (int) value.length());
JsonNode parsed = objectMapper.readTree(raw);
if (!(parsed instanceof ObjectNode response)
|| !response.hasNonNull("status")
|| !response.path("items").isArray()) {
throw new AppException("ADB 게임별 AU 조회 응답 형식이 올바르지 않습니다.");
}
return response;
} catch (AppException exception) {
throw exception;
} catch (Exception exception) {
throw new AppException("ADB 게임별 AU 조회 실패: " + exception.getMessage());
}
}
private JsonNode unwrapQueryPlan(JsonNode candidate) {
JsonNode current = candidate;
for (int depth = 0; depth < 6 && current != null && current.isObject(); depth++) {
if (current.hasNonNull("targetType")) {
return current;
}
JsonNode response = current.path("response");
if (response.isObject()) {
current = response;
continue;
}
JsonNode nested = current.path("result").path("response");
if (nested.isObject()) {
current = nested;
continue;
}
break;
}
return current;
}
private Date parseDate(String input) {
if (input == null || input.isBlank()) {
return null;
}
try {
return Date.valueOf(LocalDate.parse(input.trim()));
} catch (Exception exception) {
throw new AppException("baseDate는 YYYY-MM-DD 형식이어야 합니다.");
}
}
private BackofficeProperties.SelectAi requiredDatabase() {
BackofficeProperties.SelectAi database = properties == null ? null : properties.selectAi();
if (database == null || !database.configured()) {
throw new AppException("게임별 AU 조회 DB 설정이 필요합니다.");
}
return database;
}
private void requireActiveToken(String token) {
if (token == null || token.isBlank()
|| bearerTokenService == null
|| bearerTokenService.findByPlainToken(token.trim()) == null) {
throw new VpdTokenAccessDeniedException();
}
}
}

View File

@@ -17,6 +17,8 @@ public class McpSseService {
private static final String SELECT_AI_TOOL_PATH = "/mcp (tools/call)";
private static final String GAME_CATALOG_TOOL = "oracle.select_ai.game_catalog_resolve";
private static final String GAME_QUERY_PLAN_TOOL = "oracle.select_ai.game_query_plan";
private static final String GAME_DAILY_AU_TOOL = "oracle.select_ai.game_daily_au_lookup";
private static final String FEW_SHOT_PREFLIGHT_TOOL = "oracle.select_ai.fewshot_preflight";
private final SelectAiService selectAiService;
private final ObjectMapper objectMapper;
@@ -25,6 +27,7 @@ public class McpSseService {
private final QaVectorService qaVectorService;
private final GameScopeService gameScopeService;
private final GameCatalogVectorService gameCatalogVectorService;
private final GameDailyAuLookupService gameDailyAuLookupService;
public McpSseService(
SelectAiService selectAiService,
@@ -33,7 +36,8 @@ public class McpSseService {
McpProperties mcpProperties,
QaVectorService qaVectorService,
GameScopeService gameScopeService,
GameCatalogVectorService gameCatalogVectorService
GameCatalogVectorService gameCatalogVectorService,
GameDailyAuLookupService gameDailyAuLookupService
) {
this.selectAiService = selectAiService;
this.objectMapper = objectMapper;
@@ -42,6 +46,7 @@ public class McpSseService {
this.qaVectorService = qaVectorService;
this.gameScopeService = gameScopeService;
this.gameCatalogVectorService = gameCatalogVectorService;
this.gameDailyAuLookupService = gameDailyAuLookupService;
}
public ObjectNode handle(String contextPath, JsonNode request) {
@@ -81,8 +86,9 @@ public class McpSseService {
/** Tools registered by this MCP server. */
public List<McpToolView> registeredTools() {
return List.of(
selectAiQueryView(), selectAiShowpromptView(), qaVectorSearchView(),
qaVectorStoreView(), fewShotNl2SqlView(), gameScopeView(), gameCatalogView(), gameQueryPlanView());
selectAiQueryView(), selectAiShowpromptView(), fewShotPreflightView(), qaVectorSearchView(),
qaVectorStoreView(), fewShotNl2SqlView(), gameScopeView(), gameCatalogView(), gameQueryPlanView(),
gameDailyAuLookupView());
}
private ObjectNode initializeResult(String contextPath) {
@@ -103,12 +109,14 @@ public class McpSseService {
ArrayNode tools = objectMapper.createArrayNode();
tools.add(toolDefinition(selectAiQueryView()));
tools.add(toolDefinition(selectAiShowpromptView()));
tools.add(toolDefinition(fewShotPreflightView()));
tools.add(toolDefinition(qaVectorSearchView()));
tools.add(toolDefinition(qaVectorStoreView()));
tools.add(toolDefinition(fewShotNl2SqlView()));
tools.add(toolDefinition(gameScopeView()));
tools.add(toolDefinition(gameCatalogView()));
tools.add(toolDefinition(gameQueryPlanView()));
tools.add(toolDefinition(gameDailyAuLookupView()));
result.set("tools", tools);
return result;
}
@@ -123,7 +131,8 @@ public class McpSseService {
ObjectNode properties = objectMapper.createObjectNode();
ArrayNode required = objectMapper.createArrayNode();
if (qaVectorSearchToolName().equals(toolView.name()) || gameScopeToolName().equals(toolView.name())
if (qaVectorSearchToolName().equals(toolView.name()) || FEW_SHOT_PREFLIGHT_TOOL.equals(toolView.name())
|| gameScopeToolName().equals(toolView.name())
|| GAME_CATALOG_TOOL.equals(toolView.name()) || GAME_QUERY_PLAN_TOOL.equals(toolView.name())) {
addStringProperty(properties, "question", "few-shot 예제 SQL을 찾을 현재 질문입니다.", 4000);
ObjectNode topK = properties.putObject("topK");
@@ -133,6 +142,13 @@ public class McpSseService {
topK.put("maximum", 20);
topK.put("default", 3);
required.add("question");
} else if (GAME_DAILY_AU_TOOL.equals(toolView.name())) {
ObjectNode plan = properties.putObject("queryPlan");
plan.put("type", "object");
plan.put("description", "바로 앞 game_query_plan의 전체 결과입니다. 카탈로그가 대상별 사용자 마스터 객체를 선택합니다.");
plan.put("additionalProperties", true);
addStringProperty(properties, "baseDate", "선택 기준일(YYYY-MM-DD)입니다. 비우면 각 대상의 최신 기준일을 사용합니다.", 10);
required.add("queryPlan");
} else if (qaVectorStoreToolName().equals(toolView.name())) {
addStringProperty(properties, "question", "검토된 Select AI 예제가 답한 업무 질문입니다.", 4000);
addStringProperty(properties, "answerSql", "검토된 단일 읽기 전용 SELECT/WITH SQL입니다.", 20000);
@@ -150,6 +166,12 @@ public class McpSseService {
"바로 앞 game_query_plan의 전체 결과입니다. targetType이 NONE, SINGLE, MULTI, ALL 중 "
+ "어느 값이어도 원 질문과 함께 그대로 전달합니다.");
plan.put("additionalProperties", true);
ObjectNode preflight = properties.putObject("fewShotPreflight");
preflight.put("type", "object");
preflight.put("description",
"선택적으로 전달할 fewshot_preflight 결과입니다. 전달하지 않아도 서버가 질문을 "
+ "벡터 검색해 승인·범주 적합한 Few-shot만 적용합니다.");
preflight.put("additionalProperties", true);
}
required.add("prompt");
}
@@ -172,13 +194,15 @@ public class McpSseService {
boolean queryTool = toolName().equals(calledToolName);
boolean showpromptTool = showpromptToolName().equals(calledToolName);
boolean qaVectorSearchTool = qaVectorSearchToolName().equals(calledToolName);
boolean fewShotPreflightTool = FEW_SHOT_PREFLIGHT_TOOL.equals(calledToolName);
boolean qaVectorStoreTool = qaVectorStoreToolName().equals(calledToolName);
boolean fewShotNl2SqlTool = fewShotNl2SqlToolName().equals(calledToolName);
boolean gameScopeTool = gameScopeToolName().equals(calledToolName);
boolean gameCatalogTool = GAME_CATALOG_TOOL.equals(calledToolName);
boolean gameQueryPlanTool = GAME_QUERY_PLAN_TOOL.equals(calledToolName);
if (!queryTool && !showpromptTool && !qaVectorSearchTool && !qaVectorStoreTool && !fewShotNl2SqlTool
&& !gameScopeTool && !gameCatalogTool && !gameQueryPlanTool) {
boolean gameDailyAuTool = GAME_DAILY_AU_TOOL.equals(calledToolName);
if (!queryTool && !showpromptTool && !qaVectorSearchTool && !fewShotPreflightTool && !qaVectorStoreTool && !fewShotNl2SqlTool
&& !gameScopeTool && !gameCatalogTool && !gameQueryPlanTool && !gameDailyAuTool) {
throw new AppException("등록되지 않은 MCP tool입니다: " + calledToolName);
}
@@ -189,7 +213,10 @@ public class McpSseService {
}
JsonNode response;
try {
if (qaVectorSearchTool) {
if (fewShotPreflightTool) {
response = fewShotPreflightResponse(qaVectorService.search(
token, arguments.path("question").asText(""), arguments.path("topK").asInt(3)));
} else if (qaVectorSearchTool) {
response = qaVectorSearchResponse(
qaVectorService.search(token, arguments.path("question").asText(""), arguments.path("topK").asInt(3)));
} else if (qaVectorStoreTool) {
@@ -208,6 +235,9 @@ public class McpSseService {
arguments.path("question").asText(""),
arguments.path("topK").asInt(5)
);
} else if (gameDailyAuTool) {
response = gameDailyAuLookupService.lookup(
token, arguments.path("queryPlan"), arguments.path("baseDate").asText(""));
} else {
String prompt = arguments.path("prompt").asText("");
response = queryTool
@@ -215,7 +245,7 @@ public class McpSseService {
: fewShotNl2SqlTool
? selectAiService.generateAndExecute(token, prompt,
arguments.path("scopeGameKey").asText(""),
arguments.path("queryPlan"))
arguments.path("queryPlan"), arguments.path("fewShotPreflight"))
: selectAiService.generatePrompt(token, prompt);
}
} catch (VpdTokenAccessDeniedException ignored) {
@@ -271,6 +301,18 @@ public class McpSseService {
return response;
}
/** Preflight is intentionally independent of game identity and SQL execution. */
private ObjectNode fewShotPreflightResponse(QaVectorService.VectorSearchResult result) {
ObjectNode response = qaVectorSearchResponse(result);
response.put("status", "FEWSHOT_PREFLIGHT");
response.put("instruction",
"먼저 이 질문에 적용 가능한 일반화 Few-shot 패턴만 찾았습니다. 다음으로 동일 질문을 "
+ "game_query_plan에 전달해 ADB OCI GenAI Chat으로 NONE/SINGLE/MULTI/ALL 게임 범주를 판정하세요. "
+ "그 다음 fewshot_nl2sql에는 원 질문, 전체 queryPlan, 이 전체 preflight 결과를 함께 전달하세요.");
response.put("decision", result.examples().isEmpty() ? "NO_PATTERN" : "CANDIDATES_AVAILABLE");
return response;
}
private ObjectNode qaVectorStoreResponse(QaVectorService.VectorStoreResult stored) {
ObjectNode response = objectMapper.createObjectNode();
response.put("status", "QA_VECTOR_STORED");
@@ -349,6 +391,14 @@ public class McpSseService {
qaVectorSearchToolLabel(), SELECT_AI_TOOL_PATH);
}
private McpToolView fewShotPreflightView() {
return new McpToolView(
FEW_SHOT_PREFLIGHT_TOOL,
"필요 시 원 질문에 맞는 일반화·승인 Few-shot 후보를 미리 확인합니다. "
+ "SQL과 게임 식별은 수행하지 않으며 Text2SQL 실행의 선행 조건이 아닙니다.",
-1L, "Few-shot 적합성 사전검사", SELECT_AI_TOOL_PATH);
}
private McpToolView qaVectorStoreView() {
return new McpToolView(
qaVectorStoreToolName(), qaVectorStoreToolDescription(), -1L,
@@ -359,8 +409,8 @@ public class McpSseService {
return new McpToolView(
fewShotNl2SqlToolName(),
fewShotNl2SqlToolDescription()
+ " game_query_plan의 targetType이 NONE, SINGLE, MULTI, ALL 중 어느 값이어도 "
+ "원 질문과 전체 queryPlan을 한 번 받아 단일 읽기 전용 SQL을 생성·실행합니다. "
+ " game_query_plan이 발급한 대상별 workerArguments를 받아 단일 읽기 전용 SQL을 생성·실행합니다. "
+ "Few-shot 벡터 검색과 승인·범주 적합성 판정은 이 도구 내부에서 수행합니다. "
+ "queryPlan에 없는 게임, prefix, 물리 객체를 추측하지 않습니다.",
-1L,
fewShotNl2SqlToolLabel(), SELECT_AI_TOOL_PATH);
@@ -381,13 +431,21 @@ public class McpSseService {
private McpToolView gameQueryPlanView() {
return new McpToolView(GAME_QUERY_PLAN_TOOL,
"항상 먼저 호출해 질문의 게임 대상을 NONE, SINGLE, MULTI, ALL로 판정합니다. "
+ "targets에는 DB 카탈로그의 게임 식별자와 승인된 사용자 마스터 물리 객체명이 포함됩니다. "
+ "어떤 targetType도 종료 조건이 아닙니다. 원 질문과 이 도구의 전체 결과를 "
+ "smilegate_fewshot_nl2sql의 prompt와 queryPlan에 한 번 전달하세요. SQL은 실행하지 않습니다.",
"질문의 게임 대상을 NONE, SINGLE, MULTI, ALL로 판정하고 DB 기반 executionTasks를 발급합니다. "
+ "targets에는 DB 카탈로그의 게임 식별자와 승인된 사용자 마스터 물리 객체명이 포함됩니다. "
+ "어떤 targetType도 종료 조건이 아닙니다. QUERY task의 workerArguments를 후속 도구에 그대로 "
+ "전달하세요. SQL은 실행하지 않습니다.",
-1L, "게임 질의 계획", SELECT_AI_TOOL_PATH);
}
private McpToolView gameDailyAuLookupView() {
return new McpToolView(GAME_DAILY_AU_TOOL,
"게임별 특정일 AU 요청에는 game_query_plan 다음에 사용합니다. queryPlan의 SINGLE, MULTI, ALL "
+ "대상마다 게임 카탈로그의 승인된 사용자 마스터 객체를 동적으로 선택하고, 기준일별 AU를 "
+ "결정론적으로 집계합니다. NONE이면 대상 없음으로 반환합니다. 게임명·prefix·물리 객체를 추측하거나 입력받지 않습니다.",
-1L, "게임별 일간 AU 조회", SELECT_AI_TOOL_PATH);
}
private String selectAiProfile() {
BackofficeProperties.SelectAi selectAi = properties == null ? null : properties.selectAi();
if (selectAi == null || selectAi.profile() == null || selectAi.profile().isBlank()) {

View File

@@ -12,7 +12,9 @@ import java.time.Clock;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.regex.Pattern;
import org.springframework.stereotype.Service;
@@ -118,6 +120,67 @@ public class QaVectorService {
}
}
/**
* Rehydrates preflight candidate ids from the database before they are used
* in a generation prompt. Client-provided text is never trusted as a
* Few-shot template.
*/
public List<VectorExample> findApprovedExamples(
String bearerToken, List<Long> exampleIds, String targetType) {
requireActiveToken(bearerToken);
if (exampleIds == null || exampleIds.isEmpty()) {
return List.of();
}
List<Long> ids = exampleIds.stream().filter(id -> id != null && id > 0).distinct().limit(20).toList();
if (ids.isEmpty()) {
return List.of();
}
String normalizedTargetType = requiredTargetType(targetType);
BackofficeProperties.SelectAi selectAi = requiredSelectAi();
String placeholders = String.join(",", java.util.Collections.nCopies(ids.size(), "?"));
String query = "SELECT example_id, question, answer_sql, answer_text, embedding_model, "
+ "reference_kind, target_type, object_role, source_case_id, source_type, 0 AS cosine_distance "
+ "FROM sg_qa_vector_example WHERE reference_status = 'APPROVED' "
+ "AND source_type IN ('GENERALIZED_QUESTION_PATTERN', 'POLICY_TEMPLATE') "
+ "AND (target_type = 'ANY' OR ? = 'ANY' OR target_type = ?) "
+ "AND example_id IN (" + placeholders + ")";
Map<Long, VectorExample> found = new LinkedHashMap<>();
try (Connection connection = DriverManager.getConnection(
selectAi.dbUrl(), selectAi.dbUsername(), selectAi.dbPassword());
PreparedStatement statement = connection.prepareStatement(query)) {
statement.setString(1, normalizedTargetType);
statement.setString(2, normalizedTargetType);
for (int i = 0; i < ids.size(); i++) {
statement.setLong(i + 3, ids.get(i));
}
try (ResultSet resultSet = statement.executeQuery()) {
while (resultSet.next()) {
VectorExample example = vectorExample(resultSet);
found.put(example.exampleId(), example);
}
}
} catch (Exception exception) {
throw new AppException("Few-shot 사전검사 후보 검증 실패: " + exception.getMessage());
}
return ids.stream().map(found::get).filter(java.util.Objects::nonNull).toList();
}
private VectorExample vectorExample(ResultSet resultSet) throws java.sql.SQLException {
return new VectorExample(
resultSet.getLong("EXAMPLE_ID"),
resultSet.getString("QUESTION"),
resultSet.getString("ANSWER_SQL"),
resultSet.getString("ANSWER_TEXT"),
resultSet.getString("EMBEDDING_MODEL"),
resultSet.getString("REFERENCE_KIND"),
resultSet.getString("TARGET_TYPE"),
resultSet.getString("OBJECT_ROLE"),
resultSet.getString("SOURCE_CASE_ID"),
resultSet.getString("SOURCE_TYPE"),
resultSet.getDouble("COSINE_DISTANCE")
);
}
private BackofficeProperties.SelectAi requiredSelectAi() {
BackofficeProperties.SelectAi selectAi = properties == null ? null : properties.selectAi();
if (selectAi == null || !selectAi.configured()) {

View File

@@ -83,25 +83,40 @@ public class SelectAiService {
requireActiveToken(bearerToken);
String normalizedPrompt = requiredPrompt(prompt);
BackofficeProperties.SelectAi selectAi = requiredSelectAi();
ResolvedExecutionScope scope = resolveExecutionScope(bearerToken, normalizedPrompt, scopeGameKey);
GameContext gameContext = resolveGameContext(bearerToken, normalizedPrompt);
// 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,
gameContext.prompt(scope.prompt()),
gameContext.scopeType(),
gameContext.status(),
gameContext.candidates().size(),
queryPlan.prompt(normalizedPrompt, scope.gameKey()),
queryPlan.targetType(),
queryPlan.status(),
queryPlan.targetCount(),
scope,
null,
null
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);
@@ -111,17 +126,20 @@ public class SelectAiService {
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),
queryPlan.prompt(normalizedPrompt, scope.gameKey()),
queryPlan.targetType(),
queryPlan.status(),
queryPlan.targetCount(),
scope,
queryPlan.allowedPrefixes(),
queryPlan
queryPlan,
preflightExamples
);
}
@@ -135,27 +153,14 @@ public class SelectAiService {
int gameCandidateCount,
ResolvedExecutionScope scope,
Set<String> allowedPrefixes,
QueryPlanContext queryPlan
QueryPlanContext queryPlan,
List<QaVectorService.VectorExample> preflightExamples
) {
EnrichedPrompt enrichedPrompt = enrichWithFewShot(
bearerToken, selectAi, executionPrompt, queryPlan == null ? "ANY" : queryPlan.targetType());
bearerToken, selectAi, originalPrompt, executionPrompt,
queryPlan == null ? "ANY" : queryPlan.targetType(), preflightExamples);
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");
@@ -168,6 +173,7 @@ public class SelectAiService {
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());
@@ -189,17 +195,37 @@ public class SelectAiService {
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("targets").isArray()) {
|| !normalizedPlan.path("gameTargets").isArray()
|| normalizedPlan.path("selectAiReference").asText("").isBlank()) {
throw new AppException(
"queryPlan은 targetType(NONE/SINGLE/MULTI/ALL)과 targets 배열이 필요합니다.");
"queryPlan은 targetType, gameTargets, selectAiReference가 필요합니다.");
}
Set<String> allowedPrefixes = new LinkedHashSet<>();
for (JsonNode target : normalizedPlan.path("targets")) {
for (JsonNode target : normalizedPlan.path("gameTargets")) {
String prefix = target.path("gamePrefix").asText("").trim();
if (!prefix.isEmpty()) {
allowedPrefixes.add(prefix.toUpperCase(Locale.ROOT));
@@ -208,8 +234,9 @@ public class SelectAiService {
return new QueryPlanContext(
targetType,
normalizedPlan.path("status").asText(""),
normalizedPlan.path("targets").size(),
normalizedPlan.path("gameTargets").size(),
Set.copyOf(allowedPrefixes),
normalizedPlan.path("selectAiReference").asText().trim(),
normalizedPlan.deepCopy()
);
}
@@ -249,44 +276,6 @@ public class SelectAiService {
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");
@@ -319,7 +308,8 @@ public class SelectAiService {
requireActiveToken(bearerToken);
String normalizedPrompt = requiredPrompt(prompt);
BackofficeProperties.SelectAi selectAi = requiredSelectAi();
EnrichedPrompt enrichedPrompt = enrichWithFewShot(bearerToken, selectAi, normalizedPrompt, "ANY");
EnrichedPrompt enrichedPrompt = enrichWithFewShot(
bearerToken, selectAi, normalizedPrompt, normalizedPrompt, "ANY", List.of());
String selectAiPrompt = generate(selectAi, enrichedPrompt.prompt(), "showprompt");
ObjectNode response = objectMapper.createObjectNode();
@@ -391,27 +381,25 @@ public class SelectAiService {
}
private EnrichedPrompt enrichWithFewShot(
String bearerToken,
BackofficeProperties.SelectAi selectAi,
String prompt,
String targetType
) {
String bearerToken, BackofficeProperties.SelectAi selectAi,
String retrievalQuestion, String generationPrompt, String targetType,
List<QaVectorService.VectorExample> preflightExamples) {
if (!fewShotEnabled(selectAi) || qaVectorService == null) {
return new EnrichedPrompt(prompt, "DISABLED", 0, List.of());
return new EnrichedPrompt(generationPrompt, "DISABLED", 0, List.of());
}
try {
List<QaVectorService.VectorExample> examples = qaVectorService
.search(bearerToken, prompt, fewShotTopK(selectAi), targetType)
.examples();
List<QaVectorService.VectorExample> examples = preflightExamples == null || preflightExamples.isEmpty()
? qaVectorService.search(bearerToken, retrievalQuestion, fewShotTopK(selectAi), targetType).examples()
: preflightExamples;
if (examples.isEmpty()) {
return new EnrichedPrompt(composePolicyPrompt(prompt), "NO_MATCH", 0, List.of());
return new EnrichedPrompt(composePolicyPrompt(generationPrompt), "NO_MATCH", 0, List.of());
}
return new EnrichedPrompt(
composeFewShotPrompt(prompt, examples), "APPLIED", Math.min(examples.size(), MAX_FEW_SHOT_EXAMPLES),
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(prompt), "UNAVAILABLE", 0, List.of());
return new EnrichedPrompt(composePolicyPrompt(generationPrompt), "UNAVAILABLE", 0, List.of());
}
}
@@ -419,28 +407,38 @@ public class SelectAiService {
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"
+ "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 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 "
+ "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 examples use a game-specific object, reuse that pattern only after the current question "
+ "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 examples:\n");
int included = 0;
+ "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;
}
@@ -467,21 +465,32 @@ public class SelectAiService {
+ "\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())) {
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 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"
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);
return answerSql.isBlank() ? "" : prefix + "Verified SQL template:\n" + answerSql + "\n\n";
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) {
@@ -599,11 +608,21 @@ public class SelectAiService {
String status,
int targetCount,
Set<String> allowedPrefixes,
String selectAiReference,
JsonNode plan
) {
String prompt(String originalQuestion) {
return "[AUTHORITATIVE GAME QUERY PLAN]\n" + plan
+ "\n[ORIGINAL USER QUESTION]\n" + originalQuestion;
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) {
@@ -611,7 +630,10 @@ public class SelectAiService {
if (requested.isEmpty()) {
return new ResolvedExecutionScope("", null, null);
}
for (JsonNode target : plan.path("targets")) {
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));
@@ -621,29 +643,4 @@ public class SelectAiService {
}
}
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();
}
}
}

View File

@@ -10,10 +10,10 @@
<details class="explanation-details">
<summary>도움말</summary>
<p>
<code th:text="${catalogOwner}">OWNER</code>의 승인된 업무 TABLE/VIEW comment와 컬럼 comment를 조회·수정합니다.
<code th:text="${catalogOwner}">OWNER</code> 스키마의 등록된 업무 데이터 객체에 대한 table/column comment와 Oracle annotation을 조회·수정합니다.
Select AI profile의 <code>comments=true</code>, <code>annotations=true</code> 설정에서는 이 값들이 SQL 생성 근거로 들어갑니다.
</p>
<p class="mb-0">Oracle annotation은 TABLE에서만 관리합니다. 임의 스키마나 임의 객체는 수정하지 않습니다.</p>
<p class="mb-0">임의 스키마나 임의 객체는 수정하지 않고, 배포 환경에서 등록한 카탈로그 객체만 대상으로 합니다.</p>
</details>
</section>
@@ -24,7 +24,7 @@
<div class="section-heading">
<div>
<h2>테이블 선택</h2>
<p class="section-subtitle"><span th:text="${product.dataName()}">업무 데이터</span> 카탈로그에 등록된 TABLE/VIEW만 표시합니다.</p>
<p class="section-subtitle">정형 MCP/Select AI가 참조하는 등록 업무 데이터 객체만 표시합니다.</p>
</div>
<span class="badge text-bg-secondary" th:text="${#lists.size(tables)}">6</span>
</div>
@@ -33,9 +33,9 @@
class="structured-table-card"
th:classappend="${entry.key() == selectedKey} ? ' is-selected'"
th:href="@{/schema-metadata(table=${entry.key()})}">
<strong th:text="${entry.businessName()}">직원 원장</strong>
<strong th:text="${entry.businessName()}">게임 사용자</strong>
<code th:text="${entry.tableName()}">OBJECT_NAME</code>
<small><span th:text="${entry.objectType()}">TABLE</span> · <span th:text="${entry.description()}">설명</span></small>
<small th:text="${entry.description()}">게임 사용자 마스터</small>
</a>
</div>
</section>
@@ -46,7 +46,7 @@
<span class="badge text-bg-secondary" th:text="${metadata.table().objectType()}">TABLE</span>
<h2 class="mt-2" th:text="${metadata.table().businessName()}">직원 원장</h2>
<p class="section-subtitle">
<code th:text="${catalogOwner + '.' + metadata.table().tableName()}">OWNER.OBJECT_NAME</code>
<code th:text="${catalogOwner + '.' + metadata.table().tableName()}">OWNER.TABLE_NAME</code>
<span th:text="${' · ' + metadata.table().description()}"> · 설명</span>
</p>
</div>
@@ -128,12 +128,21 @@
th:each="column : ${metadata.columns()}"
th:open="${!#lists.isEmpty(column.annotations())}">
<summary class="d-flex justify-content-between align-items-center gap-3">
<span>
<code th:text="${column.columnName()}">CONTRACT_NO</code>
<small class="text-muted ms-2" th:text="${column.dataType()}">VARCHAR2(30)</small>
<span class="badge text-bg-light ms-2" th:text="${column.nullable()} ? 'NULL 허용' : 'NOT NULL'">NOT NULL</span>
<span class="flex-grow-1">
<span>
<code th:text="${column.columnName()}">CONTRACT_NO</code>
<small class="text-muted ms-2" th:text="${column.dataType()}">VARCHAR2(30)</small>
<span class="badge text-bg-light ms-2" th:text="${column.nullable()} ? 'NULL 허용' : 'NOT NULL'">NOT NULL</span>
</span>
<small class="d-block text-muted mt-1" th:if="${!#strings.isEmpty(column.comment())}"
th:text="${column.comment()}">컬럼 업무 설명</small>
<small class="d-block text-warning mt-1" th:if="${#strings.isEmpty(column.comment())}">컬럼 comment 없음</small>
<small class="d-block text-muted mt-1" th:each="annotation : ${column.annotations()}">
<code th:text="${annotation.name()}">DISPLAY_NAME</code>
<span th:text="${annotation.value()}">annotation 값</span>
</small>
</span>
<span class="text-muted" th:text="${#lists.size(column.annotations()) + ' annotations'}">0 annotations</span>
<span class="text-muted text-nowrap">편집</span>
</summary>
<form method="post" action="/schema-metadata/column-comment" class="mt-3">

View File

@@ -51,7 +51,8 @@ class McpSseServiceTest {
),
qaVectorService,
gameScopeService,
gameCatalogVectorService
gameCatalogVectorService,
null
);
@Test
@@ -59,7 +60,7 @@ class McpSseServiceTest {
ObjectNode response = service.handle("default", request(1, "tools/list"));
var tools = response.path("result").path("tools");
assertThat(tools).hasSize(8);
assertThat(tools).hasSize(10);
var selectAi = tools.get(0);
assertThat(selectAi.path("name").asText()).isEqualTo("oracle.select_ai.test_data_text2sql");
assertThat(selectAi.path("description").asText()).contains("SGMP_POC_OCI_GPT54MINI");
@@ -80,7 +81,12 @@ class McpSseServiceTest {
.extracting(node -> node.asText())
.contains("prompt");
var vectorSearch = tools.get(2);
var preflight = tools.get(2);
assertThat(preflight.path("name").asText())
.isEqualTo("oracle.select_ai.fewshot_preflight");
assertThat(preflight.path("description").asText()).contains("필요 시");
var vectorSearch = tools.get(3);
assertThat(vectorSearch.path("name").asText())
.isEqualTo("oracle.select_ai.test_qa_vector_search");
assertThat(vectorSearch.path("inputSchema").path("properties").path("question").path("type").asText())
@@ -88,37 +94,41 @@ class McpSseServiceTest {
assertThat(vectorSearch.path("inputSchema").path("properties").path("topK").path("default").asInt())
.isEqualTo(3);
var vectorStore = tools.get(3);
var vectorStore = tools.get(4);
assertThat(vectorStore.path("name").asText())
.isEqualTo("oracle.select_ai.test_qa_vector_store");
assertThat(vectorStore.path("inputSchema").path("required"))
.extracting(node -> node.asText())
.contains("question", "answerSql");
var fewShot = tools.get(4);
var fewShot = tools.get(5);
assertThat(fewShot.path("name").asText())
.isEqualTo("oracle.select_ai.test_fewshot_nl2sql");
assertThat(fewShot.path("description").asText())
.contains("Few-shot")
.contains("NONE, SINGLE, MULTI, ALL");
.contains("workerArguments");
assertThat(fewShot.path("inputSchema").path("properties")
.path("queryPlan").path("description").asText())
.contains("NONE, SINGLE, MULTI, ALL");
assertThat(fewShot.path("inputSchema").path("properties").has("fewShotPreflight")).isTrue();
var gameScope = tools.get(5);
var gameScope = tools.get(6);
assertThat(gameScope.path("name").asText())
.isEqualTo("oracle.select_ai.test_game_scope_resolve");
assertThat(gameScope.path("inputSchema").path("required"))
.extracting(node -> node.asText()).contains("question");
assertThat(tools.get(6).path("name").asText())
.isEqualTo("oracle.select_ai.game_catalog_resolve");
assertThat(tools.get(7).path("name").asText())
.isEqualTo("oracle.select_ai.game_catalog_resolve");
assertThat(tools.get(8).path("name").asText())
.isEqualTo("oracle.select_ai.game_query_plan");
assertThat(tools.get(7).path("description").asText())
.contains("항상 먼저 호출")
assertThat(tools.get(8).path("description").asText())
.contains("NONE, SINGLE, MULTI, ALL")
.contains("smilegate_fewshot_nl2sql");
.contains("workerArguments");
assertThat(tools.get(9).path("name").asText())
.isEqualTo("oracle.select_ai.game_daily_au_lookup");
assertThat(tools.get(9).path("inputSchema").path("required"))
.extracting(node -> node.asText()).contains("queryPlan");
}
@Test
@@ -180,6 +190,9 @@ class McpSseServiceTest {
.putNull("gameKey")
.putNull("gamePrefix")
.putNull("userMasterObjectName");
arguments.putObject("fewShotPreflight")
.put("status", "FEWSHOT_PREFLIGHT")
.putArray("examples").addObject().put("exampleId", 42L);
ObjectNode response = service.handle("default", request, "user-bearer");
@@ -333,6 +346,14 @@ class McpSseServiceTest {
return generateAndExecute(bearerToken, prompt);
}
@Override
public JsonNode generateAndExecute(
String bearerToken, String prompt, String scopeGameKey, JsonNode priorToolContext,
JsonNode fewShotPreflight) {
this.queryPlan = priorToolContext;
return generateAndExecute(bearerToken, prompt);
}
@Override
public JsonNode generatePrompt(String bearerToken, String prompt) {
this.bearerToken = bearerToken;
@@ -364,7 +385,7 @@ class McpSseServiceTest {
return new VectorSearchResult(question, topK, java.util.List.of(new VectorExample(
42L, "active user count", "SELECT COUNT(*) FROM APP_USER", "AU count",
"cohere.embed-v4.0", "SQL_TEMPLATE", "ANY", null,
"STD-13", "CUSTOMER_QA_BENCHMARK", 0.12
"PAT-STD-13", "GENERALIZED_QUESTION_PATTERN", 0.12
)));
}

View File

@@ -21,18 +21,22 @@ class SelectAiFewShotPromptTest {
"SQL_TEMPLATE",
"SINGLE",
"GAME_USER_MASTER",
"STD-16",
"CUSTOMER_QA_BENCHMARK",
"PAT-STD-16",
"GENERALIZED_QUESTION_PATTERN",
0.01
))
);
assertThat(prompt)
.contains("Verified few-shot examples")
.contains("Verified few-shot references")
.contains("SELECT COUNT(*) AS AU_COUNT FROM APP_USER")
.contains("Expected answer guidance:\nAU count")
.contains("Original user question:\ncurrent active users")
.contains("current approved object list and profile policy")
.contains("required result-shape references")
.contains("Preserve the matching aggregate versus")
.contains("do not override current metadata or game-alias resolution policy");
assertThat(prompt).doesNotContain("Verified expected result");
}
@Test
@@ -55,11 +59,12 @@ class SelectAiFewShotPromptTest {
);
assertThat(prompt)
.contains("Boundary rule")
.contains("[REQUIRED BOUNDARY REFERENCE]")
.contains("must be applied")
.contains("Do not select a game-scoped object")
.contains("SELECT CAST(NULL AS NUMBER)")
.contains("logical object role: GAME_USER_MASTER")
.contains("follow this boundary SQL template")
.contains("Use this boundary SQL template")
.contains("do not apply it to an approved common-object operation");
}
@@ -80,15 +85,4 @@ class SelectAiFewShotPromptTest {
.isEqualTo("NONE");
}
@Test
void scopesCorrectionAsGenericModelFeedbackRatherThanAnAnswerFallback() {
String correction = SelectAiService.scopeCorrectionPrompt("Original prompt");
assertThat(correction)
.contains("Original prompt")
.contains("SQL VALIDATION FEEDBACK")
.contains("Regenerate one read-only SQL statement")
.contains("active profile instructions");
}
}