refs #739: generalize game target profile guidance
This commit is contained in:
31
database/adb/84_sgmp_none_scope_common_object_guidance.sql
Normal file
31
database/adb/84_sgmp_none_scope_common_object_guidance.sql
Normal 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;
|
||||
39
database/adb/85_sgmp_no_target_null_value_template.sql
Normal file
39
database/adb/85_sgmp_no_target_null_value_template.sql
Normal 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;
|
||||
/
|
||||
18
database/adb/86_sgmp_game_scope_profile_guidance.sql
Normal file
18
database/adb/86_sgmp_game_scope_profile_guidance.sql
Normal 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';
|
||||
@@ -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을 일반화하지 않는 프롬프트 규칙을 추가했다.
|
||||
|
||||
@@ -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,9 +145,17 @@ public class SelectAiService {
|
||||
&& gameScopeService.configured()
|
||||
&& gameScopeService.referencesGameScopedObjectOutsidePrefixes(
|
||||
bearerToken, normalizedSql, allowedPrefixes)) {
|
||||
// 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();
|
||||
response.put("status", "SHOWSQL_AND_EXECUTED");
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user