refs #739: govern Smilegate few-shot references

This commit is contained in:
devmrko
2026-07-28 12:17:21 +09:00
parent d932782c9f
commit ae37235258
7 changed files with 331 additions and 14 deletions

View File

@@ -255,6 +255,11 @@ public class McpSseService {
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());
}
item.put("cosineDistance", example.cosineDistance());
}
return response;
@@ -267,6 +272,8 @@ public class McpSseService {
response.put("exampleId", stored.exampleId());
response.put("question", stored.question());
response.put("embeddingModel", stored.embeddingModel());
response.put("referenceStatus", stored.referenceStatus());
response.put("nextStep", "DRAFT 예제입니다. 실행·정책 검토 후 APPROVED로 전환해야 Few-shot 검색에 사용됩니다.");
return response;
}

View File

@@ -44,20 +44,28 @@ public class QaVectorService {
}
public VectorSearchResult search(String bearerToken, String question, int topK) {
return search(bearerToken, question, topK, "ANY");
}
/** Searches only approved examples compatible with the current game target contract. */
public VectorSearchResult search(
String bearerToken, String question, int topK, String targetType) {
requireActiveToken(bearerToken);
String normalizedQuestion = requiredText(question, "question", MAX_QUESTION_LENGTH);
if (topK < 1 || topK > 20) {
throw new AppException("topK는 1에서 20 사이여야 합니다.");
}
String normalizedTargetType = requiredTargetType(targetType);
BackofficeProperties.SelectAi selectAi = requiredSelectAi();
List<VectorExample> examples = new ArrayList<>();
try (Connection connection = DriverManager.getConnection(
selectAi.dbUrl(), selectAi.dbUsername(), selectAi.dbPassword());
CallableStatement statement = connection.prepareCall("{ ? = call sg_qa_vector_search(?, ?) }")) {
CallableStatement statement = connection.prepareCall("{ ? = call sg_qa_vector_search(?, ?, ?) }")) {
statement.registerOutParameter(1, Types.REF_CURSOR);
statement.setString(2, normalizedQuestion);
statement.setInt(3, topK);
statement.setString(4, normalizedTargetType);
statement.execute();
try (ResultSet resultSet = (ResultSet) statement.getObject(1)) {
while (resultSet.next()) {
@@ -67,6 +75,9 @@ public class QaVectorService {
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.getDouble("COSINE_DISTANCE")
));
}
@@ -96,7 +107,7 @@ public class QaVectorService {
throw new AppException("QA 벡터 예제 SQL 저장 결과가 없습니다.");
}
return new VectorStoreResult(
resultSet.getLong("EXAMPLE_ID"), normalizedQuestion, "cohere.embed-v4.0");
resultSet.getLong("EXAMPLE_ID"), normalizedQuestion, "cohere.embed-v4.0", "DRAFT");
}
} catch (AppException exception) {
throw exception;
@@ -145,6 +156,14 @@ public class QaVectorService {
return normalized;
}
private String requiredTargetType(String value) {
String normalized = value == null ? "ANY" : value.trim().toUpperCase();
if (!List.of("NONE", "SINGLE", "MULTI", "ALL", "ANY").contains(normalized)) {
throw new AppException("targetType은 NONE, SINGLE, MULTI, ALL, ANY 중 하나여야 합니다.");
}
return normalized;
}
private String optionalText(String value, String fieldName, int maximumLength) {
String normalized = value == null ? "" : value.trim();
if (normalized.isEmpty()) {
@@ -162,6 +181,9 @@ public class QaVectorService {
String answerSql,
String answer,
String embeddingModel,
String referenceKind,
String targetType,
String objectRole,
double cosineDistance
) {
}
@@ -169,6 +191,7 @@ public class QaVectorService {
public record VectorSearchResult(String question, int topK, List<VectorExample> examples) {
}
public record VectorStoreResult(long exampleId, String question, String embeddingModel) {
public record VectorStoreResult(
long exampleId, String question, String embeddingModel, String referenceStatus) {
}
}

View File

@@ -142,7 +142,7 @@ public class SelectAiService {
QueryPlanContext queryPlan
) {
EnrichedPrompt enrichedPrompt = enrichWithFewShot(
bearerToken, selectAi, executionPrompt);
bearerToken, selectAi, executionPrompt, queryPlan == null ? "ANY" : queryPlan.targetType());
String generatedSql = generate(selectAi, enrichedPrompt.prompt(), "showsql");
String normalizedSql = validateReadOnlySql(generatedSql);
if (allowedPrefixes != null && gameScopeService != null
@@ -287,6 +287,11 @@ public class SelectAiService {
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());
}
item.put("cosineDistance", example.cosineDistance());
}
}
@@ -296,7 +301,7 @@ public class SelectAiService {
requireActiveToken(bearerToken);
String normalizedPrompt = requiredPrompt(prompt);
BackofficeProperties.SelectAi selectAi = requiredSelectAi();
EnrichedPrompt enrichedPrompt = enrichWithFewShot(bearerToken, selectAi, normalizedPrompt);
EnrichedPrompt enrichedPrompt = enrichWithFewShot(bearerToken, selectAi, normalizedPrompt, "ANY");
String selectAiPrompt = generate(selectAi, enrichedPrompt.prompt(), "showprompt");
ObjectNode response = objectMapper.createObjectNode();
@@ -370,14 +375,15 @@ public class SelectAiService {
private EnrichedPrompt enrichWithFewShot(
String bearerToken,
BackofficeProperties.SelectAi selectAi,
String prompt
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))
.search(bearerToken, prompt, fewShotTopK(selectAi), targetType)
.examples();
if (examples.isEmpty()) {
return new EnrichedPrompt(composePolicyPrompt(prompt), "NO_MATCH", 0, List.of());
@@ -412,12 +418,10 @@ public class SelectAiService {
if (included >= MAX_FEW_SHOT_EXAMPLES) {
break;
}
String answerSql = truncate(example.answerSql(), MAX_FEW_SHOT_SQL_CHARS);
if (answerSql.isBlank()) {
String candidate = referenceCandidate(included + 1, example);
if (candidate.isBlank()) {
continue;
}
String candidate = "Example " + (included + 1) + " question:\n" + example.question()
+ "\nExample " + (included + 1) + " verified SQL:\n" + answerSql + "\n\n";
if (enriched.length() + candidate.length() + prompt.length() > MAX_ENRICHED_PROMPT_LENGTH) {
break;
}
@@ -430,6 +434,19 @@ public class SelectAiService {
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() + "\n";
if ("NO_TARGET".equals(example.referenceKind())
|| "OBJECT_UNAVAILABLE".equals(example.referenceKind())) {
String boundary = truncate(example.answer(), MAX_FEW_SHOT_SQL_CHARS);
return boundary.isBlank() ? "" : prefix + "Boundary outcome:\n" + boundary + "\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);

View File

@@ -363,7 +363,7 @@ class McpSseServiceTest {
this.topK = topK;
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", 0.12
"cohere.embed-v4.0", "SQL_TEMPLATE", "ANY", null, 0.12
)));
}
@@ -372,7 +372,7 @@ class McpSseServiceTest {
this.bearerToken = bearerToken;
this.question = question;
this.answerSql = answerSql;
return new VectorStoreResult(77L, question, "cohere.embed-v4.0");
return new VectorStoreResult(77L, question, "cohere.embed-v4.0", "DRAFT");
}
}

View File

@@ -17,6 +17,9 @@ class SelectAiFewShotPromptTest {
"SELECT COUNT(*) AS AU_COUNT FROM APP_USER",
"AU count",
"cohere.embed-v4.0",
"SQL_TEMPLATE",
"SINGLE",
"GAME_USER_MASTER",
0.01
))
);
@@ -29,4 +32,27 @@ class SelectAiFewShotPromptTest {
.contains("do not infer a default game")
.contains("Continue a game-neutral question with approved common objects");
}
@Test
void rendersNoTargetExamplesAsBoundaryRatherThanSqlPattern() {
String prompt = SelectAiService.composeFewShotPrompt(
"common user count without a game",
List.of(new QaVectorService.VectorExample(
5L,
"common user count without a game",
"SELECT CAST(NULL AS NUMBER) FROM DUAL WHERE 1 = 0",
"Do not select a game-scoped object when the target plan is NONE.",
"cohere.embed-v4.0",
"NO_TARGET",
"NONE",
null,
0.01
))
);
assertThat(prompt)
.contains("Boundary outcome")
.contains("Do not select a game-scoped object")
.doesNotContain("SELECT CAST(NULL AS NUMBER)");
}
}