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

@@ -0,0 +1,219 @@
-- Governed Few-shot examples for the Smilegate Select AI MCP.
-- Existing rows are preserved for audit and only APPROVED rows are retrievable.
DECLARE
PROCEDURE add_column(p_definition IN VARCHAR2) IS
BEGIN
EXECUTE IMMEDIATE 'ALTER TABLE sg_qa_vector_example ADD (' || p_definition || ')';
EXCEPTION
WHEN OTHERS THEN
IF SQLCODE != -1430 THEN
RAISE;
END IF;
END;
BEGIN
add_column('reference_status VARCHAR2(16) DEFAULT ''DRAFT'' NOT NULL');
add_column('reference_kind VARCHAR2(32) DEFAULT ''SQL_TEMPLATE'' NOT NULL');
add_column('target_type VARCHAR2(16) DEFAULT ''ANY'' NOT NULL');
add_column('object_role VARCHAR2(64)');
add_column('inspection_status VARCHAR2(16) DEFAULT ''PENDING'' NOT NULL');
add_column('inspection_note CLOB');
add_column('verified_at TIMESTAMP(6)');
add_column('verified_by VARCHAR2(128)');
END;
/
UPDATE sg_qa_vector_example
SET reference_status = 'RETIRED',
reference_kind = 'SQL_TEMPLATE',
target_type = 'SINGLE',
inspection_status = 'RETIRED',
inspection_note = 'Executed successfully but maps BUBBLYZ to the CZN physical user-master object. Conflicts with the approved game-target contract.',
verified_at = SYSTIMESTAMP,
verified_by = 'SGMP_POC_REVIEW'
WHERE example_id = 2;
UPDATE sg_qa_vector_example
SET reference_status = 'RETIRED',
reference_kind = 'OBJECT_UNAVAILABLE',
target_type = 'SINGLE',
object_role = 'GAME_ALIAS_CATALOG',
inspection_status = 'RETIRED',
inspection_note = 'Executed successfully with no BUBBLYZ alias rows, but the game literal is not a reusable object-unavailable template. The current game query plan is the authoritative boundary source.',
verified_at = SYSTIMESTAMP,
verified_by = 'SGMP_POC_REVIEW'
WHERE example_id = 3;
UPDATE sg_qa_vector_example
SET reference_status = 'APPROVED',
reference_kind = 'SQL_TEMPLATE',
target_type = 'SINGLE',
object_role = 'GAME_USER_MASTER',
inspection_status = 'VERIFIED',
inspection_note = 'Logical placeholder template. The resolved physical object must come only from the current game query plan.',
verified_at = SYSTIMESTAMP,
verified_by = 'SGMP_POC_REVIEW'
WHERE example_id = 4;
UPDATE sg_qa_vector_example
SET reference_status = 'APPROVED',
reference_kind = 'NO_TARGET',
target_type = 'NONE',
inspection_status = 'VERIFIED',
inspection_note = 'Executed successfully with no rows. Canonical boundary for an unscoped game-user-master request.',
verified_at = SYSTIMESTAMP,
verified_by = 'SGMP_POC_REVIEW'
WHERE example_id = 5;
UPDATE sg_qa_vector_example
SET reference_status = 'RETIRED',
reference_kind = 'METADATA_POLICY',
target_type = 'ANY',
object_role = 'GAME_ALIAS_CATALOG',
inspection_status = 'RETIRED',
inspection_note = 'Not directly executable: requires an unbound GAME_TERM placeholder. Game resolution is now supplied by game_query_plan.',
verified_at = SYSTIMESTAMP,
verified_by = 'SGMP_POC_REVIEW'
WHERE example_id = 6;
UPDATE sg_qa_vector_example
SET reference_status = 'RETIRED',
reference_kind = 'SQL_TEMPLATE',
target_type = 'NONE',
object_role = 'GAME_USER_MASTER',
inspection_status = 'RETIRED',
inspection_note = 'Executed successfully but chooses CZN_COMN_USER_MST for a game-unscoped question. Conflicts with the NONE target contract.',
verified_at = SYSTIMESTAMP,
verified_by = 'SGMP_POC_REVIEW'
WHERE example_id IN (7, 8);
CREATE OR REPLACE FUNCTION sg_qa_vector_store(
p_question IN CLOB,
p_answer_sql IN CLOB,
p_answer IN CLOB DEFAULT NULL
) RETURN NUMBER
AUTHID DEFINER
IS
PRAGMA AUTONOMOUS_TRANSACTION;
v_input CLOB;
v_embedding VECTOR;
v_example_id NUMBER;
BEGIN
IF p_question IS NULL OR p_answer_sql IS NULL THEN
RAISE_APPLICATION_ERROR(-20002, 'question and answer_sql are required.');
END IF;
v_input := TO_CLOB('Question: ') || p_question
|| TO_CLOB(CHR(10) || 'Answer SQL: ') || p_answer_sql
|| CASE WHEN p_answer IS NULL THEN NULL ELSE TO_CLOB(CHR(10) || 'Answer: ') || p_answer END;
v_embedding := DBMS_VECTOR.UTL_TO_EMBEDDING(
v_input,
JSON(sg_qa_vector_params('search_document'))
);
INSERT INTO sg_qa_vector_example (
question, answer_sql, answer_text, embedding_input, embedding, embedding_model,
reference_status, reference_kind, target_type, inspection_status
) VALUES (
p_question, p_answer_sql, p_answer, v_input, v_embedding, 'cohere.embed-v4.0',
'DRAFT', 'SQL_TEMPLATE', 'ANY', 'PENDING'
) RETURNING example_id INTO v_example_id;
COMMIT;
RETURN v_example_id;
EXCEPTION
WHEN OTHERS THEN
ROLLBACK;
RAISE;
END;
/
CREATE OR REPLACE FUNCTION sg_qa_vector_search(
p_question IN CLOB,
p_top_k IN PLS_INTEGER DEFAULT 3,
p_target_type IN VARCHAR2 DEFAULT 'ANY'
) RETURN SYS_REFCURSOR
AUTHID DEFINER
IS
v_query_vector VECTOR;
v_results SYS_REFCURSOR;
v_target_type VARCHAR2(16) := UPPER(TRIM(NVL(p_target_type, 'ANY')));
BEGIN
IF p_question IS NULL THEN
RAISE_APPLICATION_ERROR(-20003, 'question is required.');
END IF;
IF p_top_k IS NULL OR p_top_k < 1 OR p_top_k > 20 THEN
RAISE_APPLICATION_ERROR(-20004, 'top_k must be between 1 and 20.');
END IF;
IF v_target_type NOT IN ('NONE', 'SINGLE', 'MULTI', 'ALL', 'ANY') THEN
RAISE_APPLICATION_ERROR(-20005, 'target_type must be NONE, SINGLE, MULTI, ALL, or ANY.');
END IF;
v_query_vector := DBMS_VECTOR.UTL_TO_EMBEDDING(
p_question,
JSON(sg_qa_vector_params('search_query'))
);
OPEN v_results FOR
SELECT example_id,
question,
answer_sql,
answer_text,
embedding_model,
reference_kind,
target_type,
object_role,
vector_distance(embedding, v_query_vector, COSINE) AS cosine_distance
FROM sg_qa_vector_example
WHERE reference_status = 'APPROVED'
AND (target_type = 'ANY' OR v_target_type = 'ANY' OR target_type = v_target_type)
ORDER BY vector_distance(embedding, v_query_vector, COSINE), example_id
FETCH FIRST p_top_k ROWS ONLY;
RETURN v_results;
END;
/
CREATE OR REPLACE FUNCTION sg_qa_vector_context(
p_question IN CLOB,
p_top_k IN PLS_INTEGER DEFAULT 3,
p_target_type IN VARCHAR2 DEFAULT 'ANY'
) RETURN CLOB
AUTHID DEFINER
IS
v_results SYS_REFCURSOR;
v_id NUMBER;
v_q CLOB;
v_sql CLOB;
v_answer CLOB;
v_model VARCHAR2(128);
v_kind VARCHAR2(32);
v_target VARCHAR2(16);
v_role VARCHAR2(64);
v_dist NUMBER;
v_context CLOB := EMPTY_CLOB();
BEGIN
v_results := sg_qa_vector_search(p_question, p_top_k, p_target_type);
LOOP
FETCH v_results INTO v_id, v_q, v_sql, v_answer, v_model, v_kind, v_target, v_role, v_dist;
EXIT WHEN v_results%NOTFOUND;
v_context := v_context
|| CASE WHEN DBMS_LOB.GETLENGTH(v_context) = 0 THEN NULL ELSE CHR(10) || CHR(10) END
|| '[Example ' || v_id || ', kind=' || v_kind || ', target_type=' || v_target
|| ', cosine_distance=' || TO_CHAR(v_dist, 'FM0D000000') || ']' || CHR(10)
|| 'Question: ' || v_q || CHR(10)
|| 'Answer SQL: ' || v_sql
|| CASE WHEN v_answer IS NULL THEN NULL ELSE CHR(10) || 'Answer: ' || v_answer END;
END LOOP;
CLOSE v_results;
RETURN v_context;
END;
/
COMMENT ON COLUMN sg_qa_vector_example.reference_status IS
'Few-shot retrieval lifecycle: DRAFT, APPROVED, or RETIRED. Only APPROVED is retrievable.';
COMMENT ON COLUMN sg_qa_vector_example.reference_kind IS
'Few-shot semantic kind: SQL_TEMPLATE, NO_TARGET, OBJECT_UNAVAILABLE, or METADATA_POLICY.';
COMMENT ON COLUMN sg_qa_vector_example.target_type IS
'Applicable game query-plan target type: NONE, SINGLE, MULTI, ALL, or ANY.';
COMMENT ON COLUMN sg_qa_vector_example.object_role IS
'Logical object role; physical object names must be sourced from the current game query plan.';

View File

@@ -116,7 +116,32 @@ system prompt와 MCP 도구 설명으로 다음 절차를 안내한다.
- 생성 SQL에서 `NONE`의 prefix 전용 객체 차단 확인
- 운영 서비스 health와 Portal ReAct 실제 응답 확인
## 8. 변경 이력
## 8. Few-shot 참조 거버넌스
Few-shot 원문 SQL은 실행 이력 그대로 검색하지 않는다. 특히 특정 게임의 물리 객체명이
포함된 예제는 다른 게임 또는 게임 미지정 질의의 벡터 후보가 될 수 있으므로, 예제는
검증·정규화·승인 단계를 거쳐야 한다.
`SG_QA_VECTOR_EXAMPLE`에는 다음 참조 메타데이터를 둔다.
| 필드 | 내용 |
|---|---|
| `REFERENCE_STATUS` | `DRAFT`, `APPROVED`, `RETIRED`. 검색은 `APPROVED`만 사용한다. |
| `REFERENCE_KIND` | `SQL_TEMPLATE`, `NO_TARGET`, `OBJECT_UNAVAILABLE`, `METADATA_POLICY` |
| `TARGET_TYPE` | `NONE`, `SINGLE`, `MULTI`, `ALL`, `ANY`. 현재 game query plan과 일치하는 예제만 벡터 순위에 포함한다. |
| `OBJECT_ROLE` | 물리 테이블명이 아닌 `GAME_USER_MASTER` 등 논리 객체 역할 |
| `INSPECTION_STATUS`, `INSPECTION_NOTE` | 전수 실행·정책 검토 결과와 사유 |
| `VERIFIED_AT`, `VERIFIED_BY` | 승인 감사 이력 |
물리 테이블명은 Few-shot의 의미 규칙으로 저장하지 않는다. `SQL_TEMPLATE`
`<RESOLVED_GAME_USER_MASTER>` 같은 논리 placeholder를 사용하며, 실제 객체는 오직
현재 `queryPlan.targets[*].userMasterObjectName`에서만 해석한다. `NO_TARGET`
`OBJECT_UNAVAILABLE` 예제는 SQL 패턴이 아니라 범위 경계 안내로 프롬프트에 포함한다.
기존 예제는 삭제하지 않는다. 실행 가능성, 게임 범위 일치, 물리 객체 의존성을 전수
검사해 `RETIRED`로 분리하고, 검증된 정규화 예제만 `APPROVED`로 전환한다.
## 9. 변경 이력
- Redmine: `#739`
- Backoffice branch: `smilegate`

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)");
}
}