vignette/infra/db/init/04_audit_eval_rls.sql
Yun Chan 84eb6e2173 feat(engine): claude -p 상주 멀티턴 엔진 게이트웨이 + 실동작 검증
- engine_gateway/gateway.py: 회기당 claude -p 상주 프로세스(stream-json), 턴 직렬, budget 제한
- 세션 생성/턴/종료 HTTP API(FastAPI, :9099)
- 검증: 멀티턴 컨텍스트 유지 + prompt caching 재사용(턴2 +$0.07) 실동작 확인
2026-06-25 21:43:27 +09:00

246 lines
12 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;
CREATE POLICY p_sessions_modify ON app.sessions FOR ALL 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()
);
-- ── 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')
);
-- ── 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()
);
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')
);
-- ── 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')
);
-- 참고: 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-튜플) 로 컨텍스트를 명시 주입한다.