refs #739: generalize game target profile guidance

This commit is contained in:
devmrko
2026-07-28 13:25:00 +09:00
parent 987ea44f32
commit 98c8ccde85
6 changed files with 179 additions and 27 deletions

View File

@@ -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을 일반화하지 않는 프롬프트 규칙을 추가했다.

View File

@@ -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;

View File

@@ -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;
/

View File

@@ -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';

View File

@@ -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<QaVectorService.VectorExample> 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;
}

View File

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