From 98c8ccde85eb705a4fe7170bd0b6f43825cfafa4 Mon Sep 17 00:00:00 2001 From: devmrko Date: Tue, 28 Jul 2026 13:25:00 +0900 Subject: [PATCH] refs #739: generalize game target profile guidance --- .../design/739-game-target-contract/README.md | 16 +++++ ...sgmp_none_scope_common_object_guidance.sql | 31 ++++++++++ .../85_sgmp_no_target_null_value_template.sql | 39 ++++++++++++ .../86_sgmp_game_scope_profile_guidance.sql | 18 ++++++ .../service/SelectAiService.java | 59 ++++++++++++------- .../service/SelectAiFewShotPromptTest.java | 43 ++++++++++++-- 6 files changed, 179 insertions(+), 27 deletions(-) create mode 100644 sql/adb/84_sgmp_none_scope_common_object_guidance.sql create mode 100644 sql/adb/85_sgmp_no_target_null_value_template.sql create mode 100644 sql/adb/86_sgmp_game_scope_profile_guidance.sql diff --git a/docs/design/739-game-target-contract/README.md b/docs/design/739-game-target-contract/README.md index c99136a..14dc464 100644 --- a/docs/design/739-game-target-contract/README.md +++ b/docs/design/739-game-target-contract/README.md @@ -141,6 +141,20 @@ Few-shot 원문 SQL은 실행 이력 그대로 검색하지 않는다. 특히 반환하지 않으며, Select AI가 현재 질문의 범위를 해석해 SQL을 생성할 때만 Few-shot 근거로 사용한다. +`NONE`은 게임이 선택되지 않았다는 범위 정보이며 질의 불가 상태가 아니다. 각 경계 +예제는 `OBJECT_ROLE`로 적용 대상을 밝힌다. 예를 들어 게임별 사용자 마스터 경계는 +공통 판매·환불 같은 공통 객체 질의에 적용하지 않는다. 공통 객체로 답할 수 있는 +질문은 게임 중립 SQL로 계속 실행하고, 게임별 객체가 본질적으로 필요한 질문만 빈 +결과 또는 게임 식별 요청으로 처리한다. + +`UNMATCHED`와 `OBJECT_STATUS=UNAVAILABLE`는 `NONE`과 별도 계약이다. 요청한 +게임 전용 논리 객체를 현재 카탈로그에서 확인할 수 없다는 뜻이므로 다른 게임의 +객체로 대체하지 않는다. 해당 객체가 반드시 필요한 연산이면 실행 가능한 읽기 전용 +0행 결과로 종료한다. 이때 `NULL`을 담은 합성 1행은 빈 결과로 취급하지 않는다. +이 규칙은 특정 게임·prefix·물리 객체에 의존하지 않으며, Java 분기가 아니라 Select +AI 프로파일의 `additional_instructions`로 관리한다. 애플리케이션은 계획 전달과 +read-only·객체 범위 검증만 담당한다. + 기존 예제는 삭제하지 않는다. 실행 가능성, 게임 범위 일치, 물리 객체 의존성을 전수 검사해 `RETIRED`로 분리하고, 검증된 정규화 예제만 `APPROVED`로 전환한다. @@ -172,3 +186,5 @@ Few-shot 원문 SQL은 실행 이력 그대로 검색하지 않는다. 특히 근거로 노출했다. 운영 Portal ReAct에서 `game_query_plan → fewshot_nl2sql` 경로로 생성·실행한 SQL은 `SELECT CAST(NULL AS NUMBER) ... FROM DUAL WHERE 1 = 0`이었고, 답변 이력 `422`는 `PASS`로 기록됐다. 직접 정답 실행 분기는 두지 않는다. +- 2026-07-28: `NONE`을 질의 차단으로 해석하지 않도록 경계 예제에 논리 객체 역할을 + 부여하고, 공통 객체 질의에는 경계 SQL을 일반화하지 않는 프롬프트 규칙을 추가했다. diff --git a/sql/adb/84_sgmp_none_scope_common_object_guidance.sql b/sql/adb/84_sgmp_none_scope_common_object_guidance.sql new file mode 100644 index 0000000..e356457 --- /dev/null +++ b/sql/adb/84_sgmp_none_scope_common_object_guidance.sql @@ -0,0 +1,31 @@ +-- NONE means "no selected game", not "no executable query". +-- Keep the boundary example scoped to its logical object role so it cannot +-- be generalized to approved common-object questions. + +UPDATE sg_qa_vector_example + SET object_role = 'GAME_USER_MASTER', + answer_text = 'This boundary applies only to a game-scoped user-master operation. ' + || 'When no game identifier is resolved, do not select a prefix-specific user-master object. ' + || 'This does not prohibit an approved common-object query.', + inspection_note = 'Canonical boundary for an unscoped game-user-master request. ' + || 'It applies only to GAME_USER_MASTER and must not suppress common-object queries.' + WHERE example_id = 5 + AND reference_kind = 'NO_TARGET'; + +-- Populate logical roles from verified SQL. This is prompt metadata only; +-- runtime physical-object selection remains governed by the query plan. +UPDATE sg_qa_vector_example + SET object_role = CASE + WHEN REGEXP_LIKE(answer_sql, 'COMN_SALES_TXN', 'i') THEN 'SALES_TRANSACTION' + WHEN REGEXP_LIKE(answer_sql, 'COMN_REFUND_TXN', 'i') THEN 'REFUND_TRANSACTION' + WHEN REGEXP_LIKE(answer_sql, 'COMN_CHARACTER_MST', 'i') THEN 'GAME_CHARACTER_MASTER' + WHEN REGEXP_LIKE(answer_sql, 'COMN_USER_MST', 'i') THEN 'GAME_USER_MASTER' + ELSE object_role + END + WHERE reference_status = 'APPROVED' + AND object_role IS NULL; + +COMMENT ON COLUMN sg_qa_vector_example.object_role IS + 'Logical business object role used to bound Few-shot interpretation. It is not a runtime physical-object selector.'; + +COMMIT; diff --git a/sql/adb/85_sgmp_no_target_null_value_template.sql b/sql/adb/85_sgmp_no_target_null_value_template.sql new file mode 100644 index 0000000..6c0f85a --- /dev/null +++ b/sql/adb/85_sgmp_no_target_null_value_template.sql @@ -0,0 +1,39 @@ +-- A no-target aggregate is a successful empty-value result, not a no-row +-- execution failure. Keep the output alias from the verified logical metric. + +DECLARE + v_input CLOB; + v_embedding VECTOR; +BEGIN + SELECT TO_CLOB('Question: ') || question + || TO_CLOB(CHR(10) || 'Answer SQL: SELECT CAST(NULL AS NUMBER) AS "USER_COUNT" FROM DUAL') + || TO_CLOB(CHR(10) || 'Answer: This boundary applies only to a game-scoped user-master operation. ' + || 'When no game identifier is resolved, do not select a prefix-specific user-master object. ' + || 'Return USER_COUNT as NULL. This does not prohibit an approved common-object query.') + INTO v_input + FROM sg_qa_vector_example + WHERE example_id = 5; + v_embedding := DBMS_VECTOR.UTL_TO_EMBEDDING( + v_input, + JSON(sg_qa_vector_params('search_document')) + ); + + UPDATE sg_qa_vector_example + SET answer_sql = 'SELECT CAST(NULL AS NUMBER) AS "USER_COUNT" FROM DUAL', + answer_text = 'This boundary applies only to a game-scoped user-master operation. ' + || 'When no game identifier is resolved, do not select a prefix-specific user-master object. ' + || 'Return USER_COUNT as NULL. This does not prohibit an approved common-object query.', + embedding_input = v_input, + embedding = v_embedding, + embedding_model = 'cohere.embed-v4.0', + inspection_note = 'Canonical GAME_USER_MASTER boundary: no game target returns a NULL metric value, ' + || 'not an execution error and not a default game selection.', + verified_at = SYSTIMESTAMP, + verified_by = 'SGMP_POC_REVIEW' + WHERE example_id = 5 + AND reference_status = 'APPROVED' + AND reference_kind = 'NO_TARGET' + AND object_role = 'GAME_USER_MASTER'; + COMMIT; +END; +/ diff --git a/sql/adb/86_sgmp_game_scope_profile_guidance.sql b/sql/adb/86_sgmp_game_scope_profile_guidance.sql new file mode 100644 index 0000000..df539cd --- /dev/null +++ b/sql/adb/86_sgmp_game_scope_profile_guidance.sql @@ -0,0 +1,18 @@ +-- General target-contract guidance belongs to the Select AI profile, not application branches. +-- It contains no current game, prefix, ID, or physical object name. + +BEGIN + DBMS_CLOUD_AI.SET_ATTRIBUTE( + profile_name => 'SGMP_POC_OCI_GPT54MINI', + attribute_name => 'additional_instructions', + attribute_value => q'~Generate Oracle SQL only for the listed approved objects. Do not reference external tables. Use English aliases only. Use database comments and annotations as the source of business rules. + +When an authoritative game query plan is supplied in the user request, use its targets and statuses as the only game-scope source; do not independently rematch a game, infer a default game, or substitute one target's object for another. A plan with no selected game does not by itself prohibit a query: use an approved common object when it can answer the operation. If an operation inherently requires a game-scoped logical object and the relevant plan target is unresolved or its physical object is unavailable, return an appropriate zero-row result. A zero-row result is not a synthetic row containing a NULL value and is not an execution failure. For multiple or all resolved targets, use the supplied target identifiers to group a common object when applicable, or combine only the supplied available objects. Never invent identifiers, prefixes, or physical object names.~' + ); +END; +/ + +SELECT attribute_name, attribute_value + FROM user_cloud_ai_profile_attributes + WHERE profile_name = 'SGMP_POC_OCI_GPT54MINI' + AND attribute_name = 'additional_instructions'; diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/SelectAiService.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/SelectAiService.java index 00e5bbf..3f24367 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/service/SelectAiService.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/service/SelectAiService.java @@ -38,11 +38,7 @@ public class SelectAiService { private static final int MAX_FEW_SHOT_SQL_CHARS = 4_000; private static final int MAX_ENRICHED_PROMPT_LENGTH = 16_000; private static final String POLICY_PREFIX = - "Answer the original user question using the current approved object list and policy. " - + "If no game identifier resolves through game-alias metadata, do not select a prefix-specific object " - + "and do not infer a default game. Continue a game-neutral question with approved common objects. " - + "Only report a missing game identifier when the requested operation inherently requires a " - + "game-specific object.\n\n"; + "Answer the original user question using the current approved object list and profile policy.\n\n"; private static final Pattern UNSAFE_SQL = Pattern.compile( "(?is)\\b(?:insert|update|delete|merge|alter|drop|create|truncate|grant|revoke|" + "commit|rollback|savepoint|lock|call|exec(?:ute)?|begin|declare|for\\s+update|" @@ -149,8 +145,16 @@ public class SelectAiService { && gameScopeService.configured() && gameScopeService.referencesGameScopedObjectOutsidePrefixes( bearerToken, normalizedSql, allowedPrefixes)) { - throw new AppException( - "Select AI 생성 SQL이 게임 질의 계획에 없는 prefix 전용 객체를 참조했습니다."); + // 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(); @@ -216,9 +220,9 @@ public class SelectAiService { * This is transport normalization only; the target contract itself remains * the authoritative data-driven policy. */ - private JsonNode unwrapMcpToolResult(JsonNode candidate) { + JsonNode unwrapMcpToolResult(JsonNode candidate) { JsonNode current = candidate; - for (int depth = 0; depth < 3 && current != null; depth++) { + for (int depth = 0; depth < 6 && current != null; depth++) { if (current.isTextual()) { try { current = objectMapper.readTree(current.asText()); @@ -227,6 +231,14 @@ public class SelectAiService { return current; } } + // LangChain can forward an MCP TextContent item verbatim as + // {"type":"text","text":"{...tools/call response...}"}. Unwrap the + // transport envelope before looking for the response payload. + if (current.isObject() && "text".equals(current.path("type").asText()) + && current.path("text").isTextual()) { + current = current.path("text"); + continue; + } JsonNode response = current.path("response"); if (response.isObject() || response.isTextual()) { current = response; @@ -412,6 +424,14 @@ public class SelectAiService { + "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 examples) { StringBuilder enriched = new StringBuilder(POLICY_PREFIX + "The verified examples below are guidance only: use only relevant SQL patterns, do not invent " @@ -443,7 +463,10 @@ public class SelectAiService { 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"; + + "\nExample " + index + " reference kind: " + example.referenceKind() + + "\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())) { String boundary = truncate(example.answer(), MAX_FEW_SHOT_SQL_CHARS); @@ -451,7 +474,10 @@ public class SelectAiService { if (boundary.isBlank() || answerSql.isBlank()) { return ""; } - return prefix + "Boundary rule:\n" + boundary + 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" + + boundary + "\nVerified boundary SQL template:\n" + answerSql + "\n\n"; } String answerSql = truncate(example.answerSql(), MAX_FEW_SHOT_SQL_CHARS); @@ -576,16 +602,7 @@ public class SelectAiService { JsonNode plan ) { String prompt(String originalQuestion) { - return "Use the authoritative game query plan below without independently rematching " - + "the game scope. Always answer the original question with one read-only SQL statement. " - + "For NONE, keep the query game-neutral and never select a prefix-specific object. " - + "For SINGLE, use only the matched target metadata. For MULTI or ALL, use the supplied " - + "target identifiers with IN and GROUP BY when a common object fits the question, or " - + "combine only the supplied non-null physical objects with UNION ALL when separate " - + "objects are required. A target with a null physical object must remain unresolved and " - + "must never be substituted with another target's object. Do not invent identifiers, " - + "prefixes, or physical object names.\n" - + "[AUTHORITATIVE GAME QUERY PLAN]\n" + plan + return "[AUTHORITATIVE GAME QUERY PLAN]\n" + plan + "\n[ORIGINAL USER QUESTION]\n" + originalQuestion; } diff --git a/src/test/java/com/cloudhandson/vpdbackoffice/service/SelectAiFewShotPromptTest.java b/src/test/java/com/cloudhandson/vpdbackoffice/service/SelectAiFewShotPromptTest.java index 985c32e..89649e0 100644 --- a/src/test/java/com/cloudhandson/vpdbackoffice/service/SelectAiFewShotPromptTest.java +++ b/src/test/java/com/cloudhandson/vpdbackoffice/service/SelectAiFewShotPromptTest.java @@ -2,6 +2,7 @@ package com.cloudhandson.vpdbackoffice.service; import static org.assertj.core.api.Assertions.assertThat; +import com.fasterxml.jackson.databind.ObjectMapper; import java.util.List; import org.junit.jupiter.api.Test; @@ -30,13 +31,12 @@ class SelectAiFewShotPromptTest { .contains("Verified few-shot examples") .contains("SELECT COUNT(*) AS AU_COUNT FROM APP_USER") .contains("Original user question:\ncurrent active users") - .contains("current approved object list and policy") - .contains("do not infer a default game") - .contains("Continue a game-neutral question with approved common objects"); + .contains("current approved object list and profile policy") + .contains("do not override current metadata or game-alias resolution policy"); } @Test - void rendersNoTargetExamplesAsBoundaryRatherThanSqlPattern() { + void scopesNoTargetBoundaryToItsLogicalObjectInsteadOfBlockingCommonObjects() { String prompt = SelectAiService.composeFewShotPrompt( "common user count without a game", List.of(new QaVectorService.VectorExample( @@ -47,7 +47,7 @@ class SelectAiFewShotPromptTest { "cohere.embed-v4.0", "NO_TARGET", "NONE", - null, + "GAME_USER_MASTER", null, null, 0.01 @@ -57,7 +57,38 @@ class SelectAiFewShotPromptTest { assertThat(prompt) .contains("Boundary rule") .contains("Do not select a game-scoped object") - .contains("SELECT CAST(NULL AS NUMBER)"); + .contains("SELECT CAST(NULL AS NUMBER)") + .contains("logical object role: GAME_USER_MASTER") + .contains("follow this boundary SQL template") + .contains("do not apply it to an approved common-object operation"); + } + + @Test + void unwrapsLangChainMcpTextContentBeforeReadingTheQueryPlan() throws Exception { + ObjectMapper mapper = new ObjectMapper(); + SelectAiService service = new SelectAiService(null, null, null, mapper, null, null, null); + var plan = mapper.createObjectNode(); + plan.put("targetType", "NONE"); + plan.putArray("targets").addObject().putNull("gameKey"); + var toolEnvelope = mapper.createObjectNode(); + toolEnvelope.set("response", plan); + var textContent = mapper.createObjectNode(); + textContent.put("type", "text"); + textContent.put("text", mapper.writeValueAsString(toolEnvelope)); + + assertThat(service.unwrapMcpToolResult(textContent).path("targetType").asText()) + .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"); } }