-- ============================================================
-- 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
