- engine_gateway/gateway.py: 회기당 claude -p 상주 프로세스(stream-json), 턴 직렬, budget 제한 - 세션 생성/턴/종료 HTTP API(FastAPI, :9099) - 검증: 멀티턴 컨텍스트 유지 + prompt caching 재사용(턴2 +$0.07) 실동작 확인
246 lines
12 KiB
PL/PgSQL
246 lines
12 KiB
PL/PgSQL
-- =============================================================================
|
||
-- 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-튜플) 로 컨텍스트를 명시 주입한다.
|