-- ============================================================================= -- 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-튜플) 로 컨텍스트를 명시 주입한다.