diff --git a/docs/design/739-game-target-contract/README.md b/docs/design/739-game-target-contract/README.md index 615fe5d..b0b8957 100644 --- a/docs/design/739-game-target-contract/README.md +++ b/docs/design/739-game-target-contract/README.md @@ -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`은 +`` 같은 논리 placeholder를 사용하며, 실제 객체는 오직 +현재 `queryPlan.targets[*].userMasterObjectName`에서만 해석한다. `NO_TARGET`과 +`OBJECT_UNAVAILABLE` 예제는 SQL 패턴이 아니라 범위 경계 안내로 프롬프트에 포함한다. + +기존 예제는 삭제하지 않는다. 실행 가능성, 게임 범위 일치, 물리 객체 의존성을 전수 +검사해 `RETIRED`로 분리하고, 검증된 정규화 예제만 `APPROVED`로 전환한다. + +## 9. 변경 이력 - Redmine: `#739` - Backoffice branch: `smilegate` diff --git a/sql/adb/82_sgmp_qa_fewshot_reference_governance.sql b/sql/adb/82_sgmp_qa_fewshot_reference_governance.sql new file mode 100644 index 0000000..5ca1ae1 --- /dev/null +++ b/sql/adb/82_sgmp_qa_fewshot_reference_governance.sql @@ -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.'; diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/McpSseService.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/McpSseService.java index 8da323e..705cf07 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/service/McpSseService.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/service/McpSseService.java @@ -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; } diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/QaVectorService.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/QaVectorService.java index 73a5459..aaa3626 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/service/QaVectorService.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/service/QaVectorService.java @@ -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 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 examples) { } - public record VectorStoreResult(long exampleId, String question, String embeddingModel) { + public record VectorStoreResult( + long exampleId, String question, String embeddingModel, String referenceStatus) { } } diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/SelectAiService.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/SelectAiService.java index e9adf87..3217c46 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/service/SelectAiService.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/service/SelectAiService.java @@ -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 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); diff --git a/src/test/java/com/cloudhandson/vpdbackoffice/service/McpSseServiceTest.java b/src/test/java/com/cloudhandson/vpdbackoffice/service/McpSseServiceTest.java index 4d61673..7384a9c 100644 --- a/src/test/java/com/cloudhandson/vpdbackoffice/service/McpSseServiceTest.java +++ b/src/test/java/com/cloudhandson/vpdbackoffice/service/McpSseServiceTest.java @@ -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"); } } diff --git a/src/test/java/com/cloudhandson/vpdbackoffice/service/SelectAiFewShotPromptTest.java b/src/test/java/com/cloudhandson/vpdbackoffice/service/SelectAiFewShotPromptTest.java index a5b964d..c34d45e 100644 --- a/src/test/java/com/cloudhandson/vpdbackoffice/service/SelectAiFewShotPromptTest.java +++ b/src/test/java/com/cloudhandson/vpdbackoffice/service/SelectAiFewShotPromptTest.java @@ -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)"); + } }