-- ============================================================ -- 65_kb_select_ai_vpd_query_api.sql -- -- Natural-language KB query API backing package. -- Run as POC_2. The caller (CB_ORDS) establishes CB_AGENT_CTX before -- invoking this package, so generated SQL is still subject to POC_2 VPD. -- ============================================================ WHENEVER SQLERROR EXIT SQL.SQLCODE SET DEFINE OFF SET FEEDBACK ON PROMPT === Annotating KB schema for Select AI === COMMENT ON TABLE poc_2.kb_customers IS '고객원장. CUST_ID는 C로 시작하는 고객 식별자 예: C1001006.'; COMMENT ON COLUMN poc_2.kb_customers.cust_id IS '고객 식별자. C로 시작하는 값 예: C1001006.'; COMMENT ON TABLE poc_2.kb_products IS '상품원장 및 약관상품 마스터. PRODUCT_CD는 숫자형 상품/약관 코드 예: 41048.'; COMMENT ON COLUMN poc_2.kb_products.product_cd IS '상품 또는 약관상품 코드. 숫자형 코드 예: 41048. KB_CONTRACTS.PRODUCT_CD와 조인한다.'; COMMENT ON COLUMN poc_2.kb_products.clause_product_nm IS '약관상품명 또는 상품명. 고객 설명용 상품 비교에 사용한다.'; COMMENT ON COLUMN poc_2.kb_products.insurance_type IS '보험 대분류. 예: 자동차, 장기, 일반.'; COMMENT ON COLUMN poc_2.kb_products.product_type IS '상품 유형 또는 세부 상품군.'; COMMENT ON COLUMN poc_2.kb_products.clause_version IS '약관 버전.'; COMMENT ON COLUMN poc_2.kb_products.sale_status IS '판매 상태.'; COMMENT ON COLUMN poc_2.kb_products.active_yn IS '상품 활성 여부.'; COMMENT ON TABLE poc_2.kb_contracts IS '계약원장. CONTRACT_NO는 CT로 시작하는 계약번호이고, PRODUCT_CD는 숫자형 상품/약관 코드다.'; COMMENT ON COLUMN poc_2.kb_contracts.contract_no IS '계약번호. CT로 시작하는 값 예: CT2699001. 41048 같은 순수 숫자 코드는 계약번호가 아니라 PRODUCT_CD일 가능성이 높다.'; COMMENT ON COLUMN poc_2.kb_contracts.cust_id IS '계약 고객 식별자. KB_CUSTOMERS.CUST_ID와 조인한다.'; COMMENT ON COLUMN poc_2.kb_contracts.product_cd IS '계약의 상품/약관 코드. 숫자형 코드 예: 41048. KB_PRODUCTS.PRODUCT_CD와 조인한다.'; COMMENT ON COLUMN poc_2.kb_contracts.fc_id IS '담당 설계사 또는 FC 사용자 ID. 예: FC00789.'; COMMENT ON COLUMN poc_2.kb_contracts.fc_channel IS '담당 채널. 예: 설계사, GA, 다이렉트.'; COMMENT ON COLUMN poc_2.kb_contracts.contract_status IS '계약 상태. 예: 정상, 실효.'; COMMENT ON COLUMN poc_2.kb_contracts.premium IS '계약 보험료.'; COMMENT ON COLUMN poc_2.kb_contracts.pay_cycle IS '보험료 납입 주기.'; COMMENT ON TABLE poc_2.kb_coverages IS '담보원장. 계약번호 CONTRACT_NO 기준으로 KB_CONTRACTS와 조인해 보장/특약을 설명한다.'; COMMENT ON COLUMN poc_2.kb_coverages.contract_no IS '담보가 속한 계약번호. KB_CONTRACTS.CONTRACT_NO와 조인한다.'; COMMENT ON COLUMN poc_2.kb_coverages.cust_id IS '담보 고객 식별자.'; COMMENT ON COLUMN poc_2.kb_coverages.coverage_nm IS '담보명 또는 특약명.'; COMMENT ON COLUMN poc_2.kb_coverages.coverage_type IS '담보 유형.'; COMMENT ON COLUMN poc_2.kb_coverages.coverage_div IS '담보 구분.'; COMMENT ON COLUMN poc_2.kb_coverages.insured_amt IS '가입금액 또는 보장금액.'; COMMENT ON COLUMN poc_2.kb_coverages.renew_due_dt IS '갱신 예정일.'; COMMENT ON TABLE poc_2.kb_external_holdings IS '외부보유정보 원장. 타 보험사 보유계약과 약관상품을 고객 CUST_ID 기준으로 비교한다.'; COMMENT ON COLUMN poc_2.kb_external_holdings.cust_id IS '외부보유정보 고객 식별자. KB_CUSTOMERS.CUST_ID와 조인한다.'; COMMENT ON COLUMN poc_2.kb_external_holdings.ext_insurer IS '외부 보험사명. 예: 삼성화재.'; COMMENT ON COLUMN poc_2.kb_external_holdings.ext_product_grp IS '외부 상품군. 예: 자동차.'; COMMENT ON COLUMN poc_2.kb_external_holdings.ext_product_type IS '외부 상품 유형. 예: 개인용.'; COMMENT ON COLUMN poc_2.kb_external_holdings.ext_clause_nm IS '외부 약관상품명.'; COMMENT ON COLUMN poc_2.kb_external_holdings.ext_sale_status IS '외부 상품 판매 상태.'; COMMENT ON COLUMN poc_2.kb_external_holdings.ext_renew_month IS '외부 보유계약 갱신월.'; PROMPT === Creating VPD-aware Select AI query API === CREATE OR REPLACE PACKAGE poc_2.kb_select_ai_vpd_query_api AUTHID DEFINER AS PROCEDURE open_query( p_prompt IN CLOB, p_limit IN PLS_INTEGER, p_rows OUT SYS_REFCURSOR, p_generated_sql OUT CLOB ); END kb_select_ai_vpd_query_api; / CREATE OR REPLACE PACKAGE BODY poc_2.kb_select_ai_vpd_query_api AS c_profile_name CONSTANT VARCHAR2(128) := 'KB_AIDP_SELECTAI_GPT54_MINI_FULLMETA_PROFILE_V1'; c_max_prompt_length CONSTANT PLS_INTEGER := 4000; c_max_rows CONSTANT PLS_INTEGER := 100; FUNCTION normalized_sql(p_generated_sql IN CLOB) RETURN VARCHAR2 IS v_sql VARCHAR2(32767); v_comment_end PLS_INTEGER; BEGIN IF p_generated_sql IS NULL OR DBMS_LOB.GETLENGTH(p_generated_sql) = 0 THEN RAISE_APPLICATION_ERROR(-20811, 'Select AI did not generate SQL.'); END IF; IF DBMS_LOB.GETLENGTH(p_generated_sql) > 32767 THEN RAISE_APPLICATION_ERROR(-20812, 'Generated SQL is too long.'); END IF; v_sql := TRIM(DBMS_LOB.SUBSTR(p_generated_sql, 32767, 1)); -- Select AI sometimes wraps an otherwise valid SQL statement in markdown -- fences or adds a short leading explanation comment. Strip only those -- wrapper artifacts before the strict safety validator runs. Comments that -- remain inside the executable statement are still rejected below. LOOP v_sql := TRIM(v_sql); v_sql := REGEXP_REPLACE(v_sql, '^[[:space:]]*```[[:alpha:]]*[[:space:]]*', ''); v_sql := REGEXP_REPLACE(v_sql, '[[:space:]]*```[[:space:]]*$', ''); v_sql := TRIM(v_sql); IF SUBSTR(v_sql, 1, 2) = '--' THEN v_comment_end := INSTR(v_sql, CHR(10)); IF v_comment_end = 0 THEN RAISE_APPLICATION_ERROR(-20813, 'Select AI returned only a comment, not SQL.'); END IF; v_sql := SUBSTR(v_sql, v_comment_end + 1); ELSIF SUBSTR(v_sql, 1, 2) = '/*' THEN v_comment_end := INSTR(v_sql, '*/'); IF v_comment_end = 0 THEN RAISE_APPLICATION_ERROR(-20814, 'Unclosed generated SQL comment is not allowed.'); END IF; v_sql := SUBSTR(v_sql, v_comment_end + 2); ELSE EXIT; END IF; END LOOP; v_sql := RTRIM(TRIM(v_sql), ';'); RETURN TRIM(v_sql); END normalized_sql; PROCEDURE assert_safe_kb_select(p_sql IN VARCHAR2) IS -- Select AI normally double-quotes Oracle identifiers. Validate a quote-free -- copy so POC_2.KB_CLAIMS and "POC_2"."KB_CLAIMS" follow the same policy. v_upper VARCHAR2(32767) := REPLACE(UPPER(p_sql), '"', ''); -- Mask string literals before checking semicolons, keywords, packages, and -- data dictionary names. A harmless CASE output string should not be treated -- as executable SQL syntax. v_lexical VARCHAR2(32767) := REGEXP_REPLACE(REPLACE(UPPER(p_sql), '"', ''), '''(''''|[^''])*''', '''X'''); BEGIN IF NOT REGEXP_LIKE(v_lexical, '^(SELECT|WITH)[[:space:]]') THEN RAISE_APPLICATION_ERROR( -20813, 'Only a single SELECT or WITH query is allowed. Generated prefix: ' || SUBSTR(v_upper, 1, 160) ); END IF; IF INSTR(v_lexical, ';') > 0 OR INSTR(v_lexical, '--') > 0 OR INSTR(v_lexical, '/*') > 0 OR INSTR(v_lexical, '*/') > 0 THEN RAISE_APPLICATION_ERROR( -20814, 'Comments or multiple SQL statements remain after normalization. Generated prefix: ' || SUBSTR(v_upper, 1, 160) ); END IF; IF REGEXP_LIKE( v_lexical, '(^|[^A-Z_])(ALTER|BEGIN|COMMIT|CREATE|DECLARE|DELETE|DROP|EXECUTE|GRANT|INSERT|MERGE|REVOKE|ROLLBACK|TRUNCATE|UPDATE)([^A-Z_]|$)' ) THEN RAISE_APPLICATION_ERROR(-20815, 'Only read-only SQL is allowed.'); END IF; IF INSTR(v_lexical, 'DBMS_') > 0 OR INSTR(v_lexical, 'UTL_') > 0 OR INSTR(v_lexical, 'SYS.') > 0 OR INSTR(v_lexical, 'ADMIN.') > 0 OR INSTR(v_lexical, 'CB_') > 0 OR INSTR(v_lexical, 'ALL_') > 0 OR INSTR(v_lexical, 'DBA_') > 0 OR INSTR(v_lexical, 'USER_') > 0 OR INSTR(v_lexical, 'FOR UPDATE') > 0 THEN RAISE_APPLICATION_ERROR(-20816, 'System, backoffice, and locking objects are not allowed.'); END IF; IF NOT REGEXP_LIKE( v_lexical, '(FROM|JOIN)[[:space:]]+(POC_2[.])?KB_(CUSTOMERS|PRODUCTS|CONTRACTS|COVERAGES|CLAIMS|EXTERNAL_HOLDINGS|STAKEHOLDERS)([[:space:],)]|$)' ) THEN RAISE_APPLICATION_ERROR(-20817, 'The generated SQL must query a permitted KB business table.'); END IF; END assert_safe_kb_select; PROCEDURE open_query( p_prompt IN CLOB, p_limit IN PLS_INTEGER, p_rows OUT SYS_REFCURSOR, p_generated_sql OUT CLOB ) IS v_prompt VARCHAR2(4000); v_generation_prompt CLOB; v_generated_sql CLOB; v_sql VARCHAR2(32767); v_limit PLS_INTEGER; BEGIN IF p_prompt IS NULL OR DBMS_LOB.GETLENGTH(TRIM(p_prompt)) = 0 THEN RAISE_APPLICATION_ERROR(-20801, 'prompt is required.'); END IF; IF DBMS_LOB.GETLENGTH(p_prompt) > c_max_prompt_length THEN RAISE_APPLICATION_ERROR(-20802, 'prompt must be 4,000 characters or fewer.'); END IF; v_prompt := TRIM(DBMS_LOB.SUBSTR(p_prompt, c_max_prompt_length, 1)); IF REGEXP_LIKE(UPPER(v_prompt), 'SELECT[[:space:]]+AI') THEN RAISE_APPLICATION_ERROR(-20803, 'SELECT AI action prefixes are not allowed in prompt.'); END IF; v_limit := LEAST(GREATEST(NVL(p_limit, 50), 1), c_max_rows); v_generation_prompt := 'Return exactly one Oracle SQL SELECT or WITH statement and no markdown. ' || 'Use only these POC_2 business tables: KB_CUSTOMERS, KB_PRODUCTS, KB_CONTRACTS, ' || 'KB_COVERAGES, KB_CLAIMS, KB_EXTERNAL_HOLDINGS, KB_STAKEHOLDERS. ' || 'Never use DDL, DML, PL/SQL, system views, backoffice tables, comments, or FOR UPDATE. ' || 'The caller request is: ' || v_prompt; v_generated_sql := DBMS_CLOUD_AI.GENERATE( prompt => v_generation_prompt, profile_name => c_profile_name, action => 'showsql' ); v_sql := normalized_sql(v_generated_sql); assert_safe_kb_select(v_sql); -- CB_AGENT_CTX was set by CB_ORDS before this call. Parsing this statement -- in the same database session makes Oracle evaluate POC_2 VPD policies. OPEN p_rows FOR 'SELECT * FROM (' || v_sql || ') WHERE ROWNUM <= :row_limit' USING v_limit; p_generated_sql := v_sql; END open_query; END kb_select_ai_vpd_query_api; / SHOW ERRORS PROMPT === Granting fixed query API to CB_ORDS === GRANT EXECUTE ON poc_2.kb_select_ai_vpd_query_api TO cb_ords; PROMPT === POC_2 Select AI VPD query API ready === EXIT