refs #739: govern Smilegate few-shot references
This commit is contained in:
@@ -116,7 +116,32 @@ system prompt와 MCP 도구 설명으로 다음 절차를 안내한다.
|
|||||||
- 생성 SQL에서 `NONE`의 prefix 전용 객체 차단 확인
|
- 생성 SQL에서 `NONE`의 prefix 전용 객체 차단 확인
|
||||||
- 운영 서비스 health와 Portal ReAct 실제 응답 확인
|
- 운영 서비스 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`
|
- Redmine: `#739`
|
||||||
- Backoffice branch: `smilegate`
|
- Backoffice branch: `smilegate`
|
||||||
|
|||||||
219
sql/adb/82_sgmp_qa_fewshot_reference_governance.sql
Normal file
219
sql/adb/82_sgmp_qa_fewshot_reference_governance.sql
Normal 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.';
|
||||||
@@ -255,6 +255,11 @@ public class McpSseService {
|
|||||||
item.put("answer", example.answer());
|
item.put("answer", example.answer());
|
||||||
}
|
}
|
||||||
item.put("embeddingModel", example.embeddingModel());
|
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());
|
item.put("cosineDistance", example.cosineDistance());
|
||||||
}
|
}
|
||||||
return response;
|
return response;
|
||||||
@@ -267,6 +272,8 @@ public class McpSseService {
|
|||||||
response.put("exampleId", stored.exampleId());
|
response.put("exampleId", stored.exampleId());
|
||||||
response.put("question", stored.question());
|
response.put("question", stored.question());
|
||||||
response.put("embeddingModel", stored.embeddingModel());
|
response.put("embeddingModel", stored.embeddingModel());
|
||||||
|
response.put("referenceStatus", stored.referenceStatus());
|
||||||
|
response.put("nextStep", "DRAFT 예제입니다. 실행·정책 검토 후 APPROVED로 전환해야 Few-shot 검색에 사용됩니다.");
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -44,20 +44,28 @@ public class QaVectorService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public VectorSearchResult search(String bearerToken, String question, int topK) {
|
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);
|
requireActiveToken(bearerToken);
|
||||||
String normalizedQuestion = requiredText(question, "question", MAX_QUESTION_LENGTH);
|
String normalizedQuestion = requiredText(question, "question", MAX_QUESTION_LENGTH);
|
||||||
if (topK < 1 || topK > 20) {
|
if (topK < 1 || topK > 20) {
|
||||||
throw new AppException("topK는 1에서 20 사이여야 합니다.");
|
throw new AppException("topK는 1에서 20 사이여야 합니다.");
|
||||||
}
|
}
|
||||||
|
String normalizedTargetType = requiredTargetType(targetType);
|
||||||
BackofficeProperties.SelectAi selectAi = requiredSelectAi();
|
BackofficeProperties.SelectAi selectAi = requiredSelectAi();
|
||||||
|
|
||||||
List<VectorExample> examples = new ArrayList<>();
|
List<VectorExample> examples = new ArrayList<>();
|
||||||
try (Connection connection = DriverManager.getConnection(
|
try (Connection connection = DriverManager.getConnection(
|
||||||
selectAi.dbUrl(), selectAi.dbUsername(), selectAi.dbPassword());
|
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.registerOutParameter(1, Types.REF_CURSOR);
|
||||||
statement.setString(2, normalizedQuestion);
|
statement.setString(2, normalizedQuestion);
|
||||||
statement.setInt(3, topK);
|
statement.setInt(3, topK);
|
||||||
|
statement.setString(4, normalizedTargetType);
|
||||||
statement.execute();
|
statement.execute();
|
||||||
try (ResultSet resultSet = (ResultSet) statement.getObject(1)) {
|
try (ResultSet resultSet = (ResultSet) statement.getObject(1)) {
|
||||||
while (resultSet.next()) {
|
while (resultSet.next()) {
|
||||||
@@ -67,6 +75,9 @@ public class QaVectorService {
|
|||||||
resultSet.getString("ANSWER_SQL"),
|
resultSet.getString("ANSWER_SQL"),
|
||||||
resultSet.getString("ANSWER_TEXT"),
|
resultSet.getString("ANSWER_TEXT"),
|
||||||
resultSet.getString("EMBEDDING_MODEL"),
|
resultSet.getString("EMBEDDING_MODEL"),
|
||||||
|
resultSet.getString("REFERENCE_KIND"),
|
||||||
|
resultSet.getString("TARGET_TYPE"),
|
||||||
|
resultSet.getString("OBJECT_ROLE"),
|
||||||
resultSet.getDouble("COSINE_DISTANCE")
|
resultSet.getDouble("COSINE_DISTANCE")
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
@@ -96,7 +107,7 @@ public class QaVectorService {
|
|||||||
throw new AppException("QA 벡터 예제 SQL 저장 결과가 없습니다.");
|
throw new AppException("QA 벡터 예제 SQL 저장 결과가 없습니다.");
|
||||||
}
|
}
|
||||||
return new VectorStoreResult(
|
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) {
|
} catch (AppException exception) {
|
||||||
throw exception;
|
throw exception;
|
||||||
@@ -145,6 +156,14 @@ public class QaVectorService {
|
|||||||
return normalized;
|
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) {
|
private String optionalText(String value, String fieldName, int maximumLength) {
|
||||||
String normalized = value == null ? "" : value.trim();
|
String normalized = value == null ? "" : value.trim();
|
||||||
if (normalized.isEmpty()) {
|
if (normalized.isEmpty()) {
|
||||||
@@ -162,6 +181,9 @@ public class QaVectorService {
|
|||||||
String answerSql,
|
String answerSql,
|
||||||
String answer,
|
String answer,
|
||||||
String embeddingModel,
|
String embeddingModel,
|
||||||
|
String referenceKind,
|
||||||
|
String targetType,
|
||||||
|
String objectRole,
|
||||||
double cosineDistance
|
double cosineDistance
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
@@ -169,6 +191,7 @@ public class QaVectorService {
|
|||||||
public record VectorSearchResult(String question, int topK, List<VectorExample> examples) {
|
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) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -142,7 +142,7 @@ public class SelectAiService {
|
|||||||
QueryPlanContext queryPlan
|
QueryPlanContext queryPlan
|
||||||
) {
|
) {
|
||||||
EnrichedPrompt enrichedPrompt = enrichWithFewShot(
|
EnrichedPrompt enrichedPrompt = enrichWithFewShot(
|
||||||
bearerToken, selectAi, executionPrompt);
|
bearerToken, selectAi, executionPrompt, queryPlan == null ? "ANY" : queryPlan.targetType());
|
||||||
String generatedSql = generate(selectAi, enrichedPrompt.prompt(), "showsql");
|
String generatedSql = generate(selectAi, enrichedPrompt.prompt(), "showsql");
|
||||||
String normalizedSql = validateReadOnlySql(generatedSql);
|
String normalizedSql = validateReadOnlySql(generatedSql);
|
||||||
if (allowedPrefixes != null && gameScopeService != null
|
if (allowedPrefixes != null && gameScopeService != null
|
||||||
@@ -287,6 +287,11 @@ public class SelectAiService {
|
|||||||
item.put("answer", example.answer());
|
item.put("answer", example.answer());
|
||||||
}
|
}
|
||||||
item.put("embeddingModel", example.embeddingModel());
|
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());
|
item.put("cosineDistance", example.cosineDistance());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -296,7 +301,7 @@ public class SelectAiService {
|
|||||||
requireActiveToken(bearerToken);
|
requireActiveToken(bearerToken);
|
||||||
String normalizedPrompt = requiredPrompt(prompt);
|
String normalizedPrompt = requiredPrompt(prompt);
|
||||||
BackofficeProperties.SelectAi selectAi = requiredSelectAi();
|
BackofficeProperties.SelectAi selectAi = requiredSelectAi();
|
||||||
EnrichedPrompt enrichedPrompt = enrichWithFewShot(bearerToken, selectAi, normalizedPrompt);
|
EnrichedPrompt enrichedPrompt = enrichWithFewShot(bearerToken, selectAi, normalizedPrompt, "ANY");
|
||||||
String selectAiPrompt = generate(selectAi, enrichedPrompt.prompt(), "showprompt");
|
String selectAiPrompt = generate(selectAi, enrichedPrompt.prompt(), "showprompt");
|
||||||
|
|
||||||
ObjectNode response = objectMapper.createObjectNode();
|
ObjectNode response = objectMapper.createObjectNode();
|
||||||
@@ -370,14 +375,15 @@ public class SelectAiService {
|
|||||||
private EnrichedPrompt enrichWithFewShot(
|
private EnrichedPrompt enrichWithFewShot(
|
||||||
String bearerToken,
|
String bearerToken,
|
||||||
BackofficeProperties.SelectAi selectAi,
|
BackofficeProperties.SelectAi selectAi,
|
||||||
String prompt
|
String prompt,
|
||||||
|
String targetType
|
||||||
) {
|
) {
|
||||||
if (!fewShotEnabled(selectAi) || qaVectorService == null) {
|
if (!fewShotEnabled(selectAi) || qaVectorService == null) {
|
||||||
return new EnrichedPrompt(prompt, "DISABLED", 0, List.of());
|
return new EnrichedPrompt(prompt, "DISABLED", 0, List.of());
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
List<QaVectorService.VectorExample> examples = qaVectorService
|
List<QaVectorService.VectorExample> examples = qaVectorService
|
||||||
.search(bearerToken, prompt, fewShotTopK(selectAi))
|
.search(bearerToken, prompt, fewShotTopK(selectAi), targetType)
|
||||||
.examples();
|
.examples();
|
||||||
if (examples.isEmpty()) {
|
if (examples.isEmpty()) {
|
||||||
return new EnrichedPrompt(composePolicyPrompt(prompt), "NO_MATCH", 0, List.of());
|
return new EnrichedPrompt(composePolicyPrompt(prompt), "NO_MATCH", 0, List.of());
|
||||||
@@ -412,12 +418,10 @@ public class SelectAiService {
|
|||||||
if (included >= MAX_FEW_SHOT_EXAMPLES) {
|
if (included >= MAX_FEW_SHOT_EXAMPLES) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
String answerSql = truncate(example.answerSql(), MAX_FEW_SHOT_SQL_CHARS);
|
String candidate = referenceCandidate(included + 1, example);
|
||||||
if (answerSql.isBlank()) {
|
if (candidate.isBlank()) {
|
||||||
continue;
|
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) {
|
if (enriched.length() + candidate.length() + prompt.length() > MAX_ENRICHED_PROMPT_LENGTH) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -430,6 +434,19 @@ public class SelectAiService {
|
|||||||
return enriched.append("Original user question:\n").append(prompt).toString();
|
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) {
|
private static String truncate(String value, int maxLength) {
|
||||||
String normalized = value == null ? "" : value.trim();
|
String normalized = value == null ? "" : value.trim();
|
||||||
return normalized.length() <= maxLength ? normalized : normalized.substring(0, maxLength);
|
return normalized.length() <= maxLength ? normalized : normalized.substring(0, maxLength);
|
||||||
|
|||||||
@@ -363,7 +363,7 @@ class McpSseServiceTest {
|
|||||||
this.topK = topK;
|
this.topK = topK;
|
||||||
return new VectorSearchResult(question, topK, java.util.List.of(new VectorExample(
|
return new VectorSearchResult(question, topK, java.util.List.of(new VectorExample(
|
||||||
42L, "active user count", "SELECT COUNT(*) FROM APP_USER", "AU count",
|
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.bearerToken = bearerToken;
|
||||||
this.question = question;
|
this.question = question;
|
||||||
this.answerSql = answerSql;
|
this.answerSql = answerSql;
|
||||||
return new VectorStoreResult(77L, question, "cohere.embed-v4.0");
|
return new VectorStoreResult(77L, question, "cohere.embed-v4.0", "DRAFT");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ class SelectAiFewShotPromptTest {
|
|||||||
"SELECT COUNT(*) AS AU_COUNT FROM APP_USER",
|
"SELECT COUNT(*) AS AU_COUNT FROM APP_USER",
|
||||||
"AU count",
|
"AU count",
|
||||||
"cohere.embed-v4.0",
|
"cohere.embed-v4.0",
|
||||||
|
"SQL_TEMPLATE",
|
||||||
|
"SINGLE",
|
||||||
|
"GAME_USER_MASTER",
|
||||||
0.01
|
0.01
|
||||||
))
|
))
|
||||||
);
|
);
|
||||||
@@ -29,4 +32,27 @@ class SelectAiFewShotPromptTest {
|
|||||||
.contains("do not infer a default game")
|
.contains("do not infer a default game")
|
||||||
.contains("Continue a game-neutral question with approved common objects");
|
.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)");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user