vignette/infra/db/init/04_audit_eval_rls.sql

437 lines
20 KiB
PL/PgSQL
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

-- =============================================================================
-- Vignette · 04_audit_eval_rls.sql
-- ① 평가 + 종단추적 (app) ② 드리프트 감사 (audit) ③ ds 재귀학습 ④ RLS 정책
-- 근거: MEMORY_KNOWLEDGE_PERSONA_DESIGN.md §3.7, §4 / MASTERPLAN §3.2~3.3, §6(RLS)
-- 전제: 02_schema.sql, 03_kb.sql. 적용 순서: 02 → 03 → 04(본 파일, 마지막).
-- 정보비대칭 2-레이어(설계서 §4.1):
-- 레이어1 = AI view (current_ai_view) → visible_to[] WHERE
-- 레이어2 = 인간 RBAC×cohort (current_role/current_uid) → RLS DB레벨
-- =============================================================================
CREATE SCHEMA IF NOT EXISTS audit;
SET search_path TO app, public;
-- =============================================================================
-- 1. 평가 — 발화별 점수 (설계서 §3.7, MASTERPLAN §3.2)
-- visible_to='{evaluator}' → 학습자 비노출(응답 가공단에서 풀림).
-- =============================================================================
CREATE TABLE IF NOT EXISTS app.feedback_scores (
turn_id UUID NOT NULL REFERENCES app.turns(id) ON DELETE CASCADE,
dimension TEXT NOT NULL, -- '공감'|'개방질문'|'검증'|'이론부합'
score REAL, -- 0-5 (CTRS 0-6)
rationale TEXT,
top1_score REAL, -- CRAG 게이트(임계미달→관찰프레이밍 F-06)
visible_to TEXT[] NOT NULL DEFAULT '{evaluator}',
loop TEXT CHECK (loop IN ('fast','deep')), -- 2-tier 출처
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (turn_id, dimension)
);
-- 대안발화 제안 (deep-loop, 대시보드용)
CREATE TABLE IF NOT EXISTS app.alternative_utterance (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
turn_id UUID NOT NULL REFERENCES app.turns(id) ON DELETE CASCADE,
suggestion TEXT NOT NULL,
rationale TEXT,
visible_to TEXT[] NOT NULL DEFAULT '{evaluator,learner}', -- 코칭은 학습자 노출
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- =============================================================================
-- 2. 종단 학습자 프로파일 (설계서 §3.7) — 회기 거듭하며 나아지는지
-- EWMA(현재수준) + slope(성장속도) 동시. persistent_gaps 는 deep-loop만.
-- =============================================================================
CREATE TABLE IF NOT EXISTS app.learner_profile (
learner_id UUID PRIMARY KEY REFERENCES app.app_user(user_id) ON DELETE CASCADE,
dim_ewma JSONB NOT NULL DEFAULT '{}', -- {empathy:0.6, open_q:0.5,...}
dim_slope JSONB NOT NULL DEFAULT '{}', -- 성장속도
persistent_gaps JSONB NOT NULL DEFAULT '[]', -- deep-loop 코칭 전용(fast 미주입)
session_count INT NOT NULL DEFAULT 0,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- =============================================================================
-- 3. 감사 (audit 스키마, append-only)
-- =============================================================================
-- 페르소나 드리프트 감사 (KPI 임베딩일관성≥0.79 측정, 설계서 §3.7)
CREATE TABLE IF NOT EXISTS audit.persona_drift_log (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
session_id UUID NOT NULL,
turn_id UUID,
persona_id UUID NOT NULL,
drift_type TEXT NOT NULL, -- 'big5_violation'|'ccd_leak'|'fact_contradiction'|'style_drift'
embedding_sim REAL,
detail JSONB,
severity TEXT CHECK (severity IN ('warn','block')),
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_drift_session ON audit.persona_drift_log(session_id);
-- 인간 열람 추적 (RBAC audit, MASTERPLAN §3.3 "교수활동 감사")
CREATE TABLE IF NOT EXISTS audit.audit_log (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
actor_uid UUID NOT NULL,
action TEXT NOT NULL, -- 'read_session'|'override_label'|...
target_kind TEXT,
target_id TEXT,
detail JSONB,
inference_geo TEXT, -- 'kr'|'us'
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_audit_actor ON audit.audit_log(actor_uid, created_at DESC);
-- LLM 호출 로그 (비용·드리프트·inference_geo, MASTERPLAN §3.2)
CREATE TABLE IF NOT EXISTS audit.llm_call_log (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
session_id UUID,
turn_id UUID,
provider TEXT, model TEXT,
tokens_in INT, tokens_out INT, cost_usd NUMERIC(12,6),
inference_geo TEXT,
latency_ms INT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- =============================================================================
-- 4. ds 스키마 — 재귀학습 파이프라인 (MASTERPLAN §3.2)
-- AI자동(1R) → 인간검수(2R) → IAA 게이트(κ≥0.6,ICC≥0.75) → 골든셋 → JSONL
-- =============================================================================
CREATE SCHEMA IF NOT EXISTS ds;
CREATE TABLE IF NOT EXISTS ds.dataset (
dataset_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
name TEXT NOT NULL,
purpose TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS ds.dataset_item (
item_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
dataset_id BIGINT NOT NULL REFERENCES ds.dataset(dataset_id) ON DELETE CASCADE,
turn_id UUID,
payload JSONB NOT NULL
);
CREATE TABLE IF NOT EXISTS ds.annotation_round (
round_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
dataset_id BIGINT NOT NULL REFERENCES ds.dataset(dataset_id) ON DELETE CASCADE,
round_kind TEXT NOT NULL CHECK (round_kind IN ('ai_auto','human_review')),
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS ds.annotation (
annotation_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
round_id BIGINT NOT NULL REFERENCES ds.annotation_round(round_id) ON DELETE CASCADE,
item_id BIGINT NOT NULL REFERENCES ds.dataset_item(item_id) ON DELETE CASCADE,
annotator TEXT,
labels JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS ds.export_manifest (
export_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
dataset_id BIGINT NOT NULL REFERENCES ds.dataset(dataset_id),
iaa_kappa REAL, iaa_icc REAL, -- 게이트 통과 기록
jsonl_path TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- =============================================================================
-- 5. RLS — 인간 RBAC×cohort 이중강제 (레이어2, MASTERPLAN §3.3)
-- 세션 GUC 로 컨텍스트 주입(BFF/FastAPI 미들웨어):
-- app.current_uid : 현재 인간 사용자 UUID
-- app.current_role : 'learner'|'instructor'|'admin'
-- app.current_cohort: 교수자 담당 코호트
-- AI 엔진(서비스롤)은 BYPASSRLS 또는 app.ai_context='1' 로 우회(레이어1 visible_to 가 방어).
-- =============================================================================
-- 헬퍼: 현재 컨텍스트 안전 추출(미설정 시 NULL/빈).
CREATE OR REPLACE FUNCTION app.current_uid() RETURNS UUID
LANGUAGE sql STABLE AS
$$ SELECT NULLIF(current_setting('app.current_uid', true), '')::uuid $$;
CREATE OR REPLACE FUNCTION app.current_role_name() RETURNS TEXT
LANGUAGE sql STABLE AS
$$ SELECT COALESCE(NULLIF(current_setting('app.current_role', true), ''), 'learner') $$;
CREATE OR REPLACE FUNCTION app.is_ai_context() RETURNS BOOLEAN
LANGUAGE sql STABLE AS
$$ SELECT current_setting('app.ai_context', true) = '1' $$;
-- ── sessions: 학습자=본인 / 교수자=담당 코호트 / 관리자=전부 ──
ALTER TABLE app.sessions ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS p_sessions_select ON app.sessions;
CREATE POLICY p_sessions_select ON app.sessions FOR SELECT USING (
app.is_ai_context()
OR app.current_role_name() = 'admin'
OR (app.current_role_name() = 'learner' AND learner_id = app.current_uid())
OR (app.current_role_name() = 'instructor' AND EXISTS (
SELECT 1 FROM app.app_user u
WHERE u.user_id = app.sessions.learner_id
AND u.cohort = current_setting('app.current_cohort', true)))
);
DROP POLICY IF EXISTS p_sessions_modify ON app.sessions;
DROP POLICY IF EXISTS p_sessions_insert ON app.sessions;
DROP POLICY IF EXISTS p_sessions_update ON app.sessions;
DROP POLICY IF EXISTS p_sessions_delete ON app.sessions;
CREATE POLICY p_sessions_insert ON app.sessions FOR INSERT WITH CHECK (
app.is_ai_context() OR app.current_role_name() IN ('admin','instructor')
OR learner_id = app.current_uid()
);
CREATE POLICY p_sessions_update ON app.sessions FOR UPDATE USING (
app.is_ai_context() OR app.current_role_name() IN ('admin','instructor')
OR learner_id = app.current_uid()
) WITH CHECK (
app.is_ai_context() OR app.current_role_name() IN ('admin','instructor')
OR learner_id = app.current_uid()
);
CREATE POLICY p_sessions_delete ON app.sessions FOR DELETE USING (
app.is_ai_context() OR app.current_role_name() IN ('admin','instructor')
OR learner_id = app.current_uid()
);
-- ── turns: 세션 소유 + visible_to(레이어1) 동시 ──
ALTER TABLE app.turns ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS p_turns_select ON app.turns;
CREATE POLICY p_turns_select ON app.turns FOR SELECT USING (
-- AI 경로: visible_to 가 ai_view 와 교집합일 때만(레이어1)
( app.is_ai_context()
AND current_setting('app.current_ai_view', true) = ANY(visible_to) )
OR
-- 인간 경로: 세션 RLS 통과 + (학습자면 evaluator-only 차단)
( NOT app.is_ai_context()
AND EXISTS (SELECT 1 FROM app.sessions s WHERE s.id = app.turns.session_id)
AND ( app.current_role_name() IN ('admin','instructor')
OR 'counselor' = ANY(visible_to) ) )
);
-- ── feedback_scores: 평가전용. 학습자 직접열람 차단(응답 가공단에서만 노출) ──
ALTER TABLE app.feedback_scores ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS p_feedback_select ON app.feedback_scores;
CREATE POLICY p_feedback_select ON app.feedback_scores FOR SELECT USING (
app.is_ai_context()
OR app.current_role_name() IN ('admin','instructor')
);
DROP POLICY IF EXISTS p_feedback_insert ON app.feedback_scores;
CREATE POLICY p_feedback_insert ON app.feedback_scores FOR INSERT WITH CHECK (
app.is_ai_context()
OR app.current_role_name() IN ('admin','instructor')
);
DROP POLICY IF EXISTS p_feedback_update ON app.feedback_scores;
CREATE POLICY p_feedback_update ON app.feedback_scores FOR UPDATE USING (
app.is_ai_context()
OR app.current_role_name() IN ('admin','instructor')
) WITH CHECK (
app.is_ai_context()
OR app.current_role_name() IN ('admin','instructor')
);
DROP POLICY IF EXISTS p_feedback_delete ON app.feedback_scores;
CREATE POLICY p_feedback_delete ON app.feedback_scores FOR DELETE USING (
app.is_ai_context()
OR app.current_role_name() IN ('admin','instructor')
);
-- ── normalized turn evaluation tables: 원시 평가는 evaluator/admin만 직접 접근 ──
ALTER TABLE app.turn_technique ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS p_turn_technique_select ON app.turn_technique;
CREATE POLICY p_turn_technique_select ON app.turn_technique FOR SELECT USING (
app.is_ai_context()
OR app.current_role_name() IN ('admin','instructor')
);
DROP POLICY IF EXISTS p_turn_technique_insert ON app.turn_technique;
CREATE POLICY p_turn_technique_insert ON app.turn_technique FOR INSERT WITH CHECK (
app.is_ai_context()
OR app.current_role_name() IN ('admin','instructor')
);
DROP POLICY IF EXISTS p_turn_technique_delete ON app.turn_technique;
CREATE POLICY p_turn_technique_delete ON app.turn_technique FOR DELETE USING (
app.is_ai_context()
OR app.current_role_name() IN ('admin','instructor')
);
ALTER TABLE app.turn_client_state ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS p_turn_client_state_select ON app.turn_client_state;
CREATE POLICY p_turn_client_state_select ON app.turn_client_state FOR SELECT USING (
app.is_ai_context()
OR app.current_role_name() IN ('admin','instructor')
);
DROP POLICY IF EXISTS p_turn_client_state_insert ON app.turn_client_state;
CREATE POLICY p_turn_client_state_insert ON app.turn_client_state FOR INSERT WITH CHECK (
app.is_ai_context()
OR app.current_role_name() IN ('admin','instructor')
);
DROP POLICY IF EXISTS p_turn_client_state_delete ON app.turn_client_state;
CREATE POLICY p_turn_client_state_delete ON app.turn_client_state FOR DELETE USING (
app.is_ai_context()
OR app.current_role_name() IN ('admin','instructor')
);
ALTER TABLE app.supervisor_comment ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS p_supervisor_comment_select ON app.supervisor_comment;
CREATE POLICY p_supervisor_comment_select ON app.supervisor_comment FOR SELECT USING (
app.is_ai_context()
OR app.current_role_name() IN ('admin','instructor')
);
DROP POLICY IF EXISTS p_supervisor_comment_insert ON app.supervisor_comment;
CREATE POLICY p_supervisor_comment_insert ON app.supervisor_comment FOR INSERT WITH CHECK (
app.is_ai_context()
OR app.current_role_name() IN ('admin','instructor')
);
DROP POLICY IF EXISTS p_supervisor_comment_delete ON app.supervisor_comment;
CREATE POLICY p_supervisor_comment_delete ON app.supervisor_comment FOR DELETE USING (
app.is_ai_context()
OR app.current_role_name() IN ('admin','instructor')
);
-- ── session_review_status: 교수자 회기 단위 작업 상태. 학습자 직접 열람 없음 ──
ALTER TABLE app.session_review_status ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS p_session_review_status_select ON app.session_review_status;
CREATE POLICY p_session_review_status_select ON app.session_review_status FOR SELECT USING (
app.current_role_name() IN ('admin','instructor')
);
DROP POLICY IF EXISTS p_session_review_status_insert ON app.session_review_status;
CREATE POLICY p_session_review_status_insert ON app.session_review_status FOR INSERT WITH CHECK (
app.current_role_name() IN ('admin','instructor')
);
DROP POLICY IF EXISTS p_session_review_status_update ON app.session_review_status;
CREATE POLICY p_session_review_status_update ON app.session_review_status FOR UPDATE USING (
app.current_role_name() IN ('admin','instructor')
) WITH CHECK (
app.current_role_name() IN ('admin','instructor')
);
DROP POLICY IF EXISTS p_session_review_status_delete ON app.session_review_status;
CREATE POLICY p_session_review_status_delete ON app.session_review_status FOR DELETE USING (
app.current_role_name() IN ('admin','instructor')
);
ALTER TABLE app.alternative_utterance ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS p_alternative_utterance_select ON app.alternative_utterance;
CREATE POLICY p_alternative_utterance_select ON app.alternative_utterance FOR SELECT USING (
app.is_ai_context()
OR app.current_role_name() IN ('admin','instructor')
);
DROP POLICY IF EXISTS p_alternative_utterance_insert ON app.alternative_utterance;
CREATE POLICY p_alternative_utterance_insert ON app.alternative_utterance FOR INSERT WITH CHECK (
app.is_ai_context()
OR app.current_role_name() IN ('admin','instructor')
);
DROP POLICY IF EXISTS p_alternative_utterance_delete ON app.alternative_utterance;
CREATE POLICY p_alternative_utterance_delete ON app.alternative_utterance FOR DELETE USING (
app.is_ai_context()
OR app.current_role_name() IN ('admin','instructor')
);
-- ── safety_events: AI/교수자/관리자와 본인 회기의 학습자만 접근 ──
ALTER TABLE app.safety_events ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS p_safety_events_select ON app.safety_events;
DROP POLICY IF EXISTS p_safety_events_insert ON app.safety_events;
CREATE POLICY p_safety_events_select ON app.safety_events FOR SELECT USING (
app.is_ai_context()
OR app.current_role_name() IN ('admin','instructor')
OR EXISTS (
SELECT 1 FROM app.sessions s
WHERE s.id = app.safety_events.session_id
AND s.learner_id = app.current_uid()
)
);
CREATE POLICY p_safety_events_insert ON app.safety_events FOR INSERT WITH CHECK (
app.is_ai_context()
OR app.current_role_name() IN ('admin','instructor')
OR EXISTS (
SELECT 1 FROM app.sessions s
WHERE s.id = app.safety_events.session_id
AND s.learner_id = app.current_uid()
)
);
-- ── case_profile / pinned_fact: 학습자=본인 케이스만(cross-trainee 누수 차단 T4) ──
ALTER TABLE app.case_profile ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS p_case_select ON app.case_profile;
CREATE POLICY p_case_select ON app.case_profile FOR SELECT USING (
app.is_ai_context()
OR app.current_role_name() IN ('admin','instructor')
OR learner_id = app.current_uid()
);
DROP POLICY IF EXISTS p_case_insert ON app.case_profile;
CREATE POLICY p_case_insert ON app.case_profile FOR INSERT WITH CHECK (
app.is_ai_context()
OR app.current_role_name() IN ('admin','instructor')
OR learner_id = app.current_uid()
);
DROP POLICY IF EXISTS p_case_update ON app.case_profile;
CREATE POLICY p_case_update ON app.case_profile FOR UPDATE USING (
app.is_ai_context()
OR app.current_role_name() IN ('admin','instructor')
OR learner_id = app.current_uid()
) WITH CHECK (
app.is_ai_context()
OR app.current_role_name() IN ('admin','instructor')
OR learner_id = app.current_uid()
);
ALTER TABLE app.pinned_fact ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS p_pinned_select ON app.pinned_fact;
CREATE POLICY p_pinned_select ON app.pinned_fact FOR SELECT USING (
( app.is_ai_context()
AND current_setting('app.current_ai_view', true) = ANY(visible_to) )
OR app.current_role_name() IN ('admin','instructor')
);
DROP POLICY IF EXISTS p_pinned_insert ON app.pinned_fact;
CREATE POLICY p_pinned_insert ON app.pinned_fact FOR INSERT WITH CHECK (
app.current_role_name() IN ('admin','instructor')
OR EXISTS (
SELECT 1 FROM app.case_profile cp
WHERE cp.case_id = app.pinned_fact.case_id
AND cp.learner_id = app.current_uid()
)
);
DROP POLICY IF EXISTS p_pinned_update ON app.pinned_fact;
CREATE POLICY p_pinned_update ON app.pinned_fact FOR UPDATE USING (
app.current_role_name() IN ('admin','instructor')
OR EXISTS (
SELECT 1 FROM app.case_profile cp
WHERE cp.case_id = app.pinned_fact.case_id
AND cp.learner_id = app.current_uid()
)
) WITH CHECK (
app.current_role_name() IN ('admin','instructor')
OR EXISTS (
SELECT 1 FROM app.case_profile cp
WHERE cp.case_id = app.pinned_fact.case_id
AND cp.learner_id = app.current_uid()
)
);
-- ── learner_profile: 학습자=본인, persistent_gaps 는 응답단 필터 ──
ALTER TABLE app.learner_profile ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS p_learner_select ON app.learner_profile;
CREATE POLICY p_learner_select ON app.learner_profile FOR SELECT USING (
app.is_ai_context()
OR app.current_role_name() IN ('admin','instructor')
OR learner_id = app.current_uid()
);
-- ── kb.chunk: 정보비대칭(visible_to + sensitivity) DB 강제 ──
ALTER TABLE kb.chunk ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS p_kb_chunk_select ON kb.chunk;
CREATE POLICY p_kb_chunk_select ON kb.chunk FOR SELECT USING (
-- AI 경로: 자기 view 가 visible_to 에 포함 + sensitivity 상한(엔진이 GUC 로 주입)
( app.is_ai_context()
AND current_setting('app.current_ai_view', true) = ANY(visible_to)
AND sensitivity <= COALESCE(
NULLIF(current_setting('app.current_sens_max', true), '')::smallint, 0) )
-- 인간(교수/관리자) 검수: 전체 가시
OR app.current_role_name() IN ('admin','instructor')
);
-- 프로토콜 등록/활성화 경로는 admin 트랜잭션 안에서 chunk를 인덱싱·퇴역한다.
-- SELECT 정책만으로는 INSERT/UPDATE/DELETE가 모두 거부되므로 쓰기 권한은 admin에만 연다.
DROP POLICY IF EXISTS p_kb_chunk_admin_write ON kb.chunk;
CREATE POLICY p_kb_chunk_admin_write ON kb.chunk
FOR ALL
USING (app.current_role_name() = 'admin')
WITH CHECK (app.current_role_name() = 'admin');
-- 참고: AI 엔진 DB 롤은 ALTER ROLE engine_svc BYPASSRLS 또는 매 연결에서
-- SET app.ai_context='1'; SET app.current_ai_view='client';
-- SET app.current_sens_max='1'; (정책별 4-튜플) 로 컨텍스트를 명시 주입한다.