feat(engine): claude -p 상주 멀티턴 엔진 게이트웨이 + 실동작 검증
- engine_gateway/gateway.py: 회기당 claude -p 상주 프로세스(stream-json), 턴 직렬, budget 제한 - 세션 생성/턴/종료 HTTP API(FastAPI, :9099) - 검증: 멀티턴 컨텍스트 유지 + prompt caching 재사용(턴2 +$0.07) 실동작 확인
This commit is contained in:
parent
d5b86c5f89
commit
84eb6e2173
20 changed files with 2038 additions and 1 deletions
371
infra/db/init/02_schema.sql
Normal file
371
infra/db/init/02_schema.sql
Normal file
|
|
@ -0,0 +1,371 @@
|
|||
-- =============================================================================
|
||||
-- Vignette · 02_schema.sql
|
||||
-- app 스키마: 세션 + 라벨 코드테이블 + 페르소나(불변 카드) + 메모리 4계층
|
||||
-- (① working / ② episodic / ③ summary / ④ semantic)
|
||||
-- 근거: MEMORY_KNOWLEDGE_PERSONA_DESIGN.md §3.1~3.5, MASTERPLAN.md §3.2
|
||||
-- 전제: 01_extensions.sql 이 vector 확장 + 스키마(app/audit/kb) 선생성.
|
||||
-- 적용 순서: 01 → 02(본 파일) → 03_kb → 04_audit_eval_rls
|
||||
-- 원칙(설계서 §0.3): 원본 append-only, 상태수치 결정론 carry-over, visible_to DB 강제.
|
||||
-- Phase 0 에 전체 선반영(대부분 nullable), cross-session 채우기는 Phase 2a.
|
||||
-- =============================================================================
|
||||
|
||||
SET search_path TO app, public;
|
||||
|
||||
-- 01_extensions.sql 미적용 환경에서도 단독 실행 가능하도록 방어적 보강.
|
||||
CREATE EXTENSION IF NOT EXISTS vector;
|
||||
CREATE EXTENSION IF NOT EXISTS pgcrypto; -- gen_random_uuid()
|
||||
CREATE SCHEMA IF NOT EXISTS app;
|
||||
CREATE SCHEMA IF NOT EXISTS audit;
|
||||
CREATE SCHEMA IF NOT EXISTS kb;
|
||||
|
||||
-- =============================================================================
|
||||
-- 0. 인적 주체 (RBAC 앵커) — 학습자/교수자/관리자
|
||||
-- visible_to / RLS 가 참조하는 최소 사용자 테이블. SSO 매핑은 BFF 소유.
|
||||
-- =============================================================================
|
||||
CREATE TABLE IF NOT EXISTS app.app_user (
|
||||
user_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
external_id TEXT UNIQUE, -- Google OIDC sub 등
|
||||
email TEXT,
|
||||
display_name TEXT,
|
||||
role TEXT NOT NULL DEFAULT 'learner'
|
||||
CHECK (role IN ('learner','instructor','admin')),
|
||||
cohort TEXT, -- 교수자 담당 코호트 매칭
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
consent_at TIMESTAMPTZ, -- 사전동의 하드게이트(IRB)
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_app_user_cohort ON app.app_user(cohort);
|
||||
|
||||
|
||||
-- =============================================================================
|
||||
-- 1. 페르소나 — 불변 카드 (설계서 §3.1)
|
||||
-- 람다 IDENTITY/SOUL 차용. 버전드, 교수만 편집. 진행 세션은 옛 version 핀.
|
||||
-- =============================================================================
|
||||
CREATE TABLE IF NOT EXISTS app.persona_card (
|
||||
persona_id UUID NOT NULL DEFAULT gen_random_uuid(),
|
||||
code TEXT NOT NULL, -- 'P1','P2','P3'
|
||||
version INT NOT NULL DEFAULT 1, -- 편집 시 +1
|
||||
status TEXT NOT NULL DEFAULT 'draft'
|
||||
CHECK (status IN ('draft','review','approved','archived')),
|
||||
display_name TEXT NOT NULL,
|
||||
difficulty TEXT NOT NULL CHECK (difficulty IN ('easy','moderate','hard')),
|
||||
theory_target TEXT[], -- {'humanistic','cbt'}
|
||||
demographics JSONB NOT NULL, -- 범주화(재식별 방지 F-25)
|
||||
presenting JSONB NOT NULL, -- 표층 호소
|
||||
history JSONB NOT NULL, -- 과거사
|
||||
big5 JSONB NOT NULL, -- {O,C,E,A,N} 0~1
|
||||
resistance JSONB NOT NULL, -- {base_resistance,unlock_rate,decay_floor,...}
|
||||
speech_style JSONB NOT NULL, -- {register,avg_sentence_len,fillers,...}
|
||||
affect_baseline JSONB NOT NULL, -- {...,suicide_ideation_stage} hard상한=3
|
||||
ccd JSONB NOT NULL, -- Patient-Ψ 8요소 (★직접발화 금지 R4)
|
||||
dsm5_dimensional JSONB NOT NULL, -- criteria_behavior_matrix (진단명 비노출)
|
||||
source_provenance TEXT NOT NULL, -- '0615 합성변형'
|
||||
is_synthetic BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_by UUID REFERENCES app.app_user(user_id),
|
||||
approved_by UUID REFERENCES app.app_user(user_id),
|
||||
approved_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (persona_id, version),
|
||||
UNIQUE (code, version)
|
||||
);
|
||||
-- 승인된 최신 카드 빠른 조회 (회기 시작 시 카드 핀).
|
||||
CREATE INDEX IF NOT EXISTS idx_persona_card_status
|
||||
ON app.persona_card(status) WHERE status = 'approved';
|
||||
CREATE INDEX IF NOT EXISTS idx_persona_card_code ON app.persona_card(code, version DESC);
|
||||
|
||||
-- 음성 매핑 (provider-agnostic, 카드 버전 정합)
|
||||
CREATE TABLE IF NOT EXISTS app.persona_voice_map (
|
||||
persona_id UUID NOT NULL,
|
||||
version INT NOT NULL,
|
||||
voice_id TEXT NOT NULL, -- 'voice_p1_teen_m'
|
||||
provider TEXT NOT NULL CHECK (provider IN ('openai','higgs','melotts')),
|
||||
base_params JSONB NOT NULL,
|
||||
prosody_map JSONB NOT NULL, -- 상태→prosody 함수
|
||||
PRIMARY KEY (persona_id, version),
|
||||
FOREIGN KEY (persona_id, version)
|
||||
REFERENCES app.persona_card(persona_id, version) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- 상담사 AI 페르소나 (데모/self-play용, 이론 충실도 일관)
|
||||
CREATE TABLE IF NOT EXISTS app.counselor_profile (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
code TEXT,
|
||||
theory TEXT CHECK (theory IN ('humanistic','cbt','integrative')),
|
||||
skill_level TEXT,
|
||||
reasoning_chain JSONB, -- CoE: PCT/CBT 추론체인
|
||||
allowed_microskills TEXT[],
|
||||
speech_style JSONB,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
|
||||
-- =============================================================================
|
||||
-- 2. 라벨 코드테이블 (MASTERPLAN §3.2, taxonomy 3축 분리)
|
||||
-- 버전드 코드테이블 + 발화 다대다. KB taxonomy 청크가 label_id 로 참조.
|
||||
-- =============================================================================
|
||||
CREATE TABLE IF NOT EXISTS app.stage_def (
|
||||
stage_code TEXT PRIMARY KEY, -- '라포','탐색','개입','정리'
|
||||
display_name TEXT NOT NULL,
|
||||
seq INT NOT NULL,
|
||||
base_openness REAL NOT NULL DEFAULT 0.15
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS app.technique_label_def (
|
||||
label_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
code TEXT NOT NULL, -- '공감','반영','탐색','홀딩'...
|
||||
display_name TEXT NOT NULL,
|
||||
category TEXT, -- 마이크로스킬 분류
|
||||
version INT NOT NULL DEFAULT 1,
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
UNIQUE (code, version)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS app.client_state_def (
|
||||
label_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
code TEXT NOT NULL, -- '비자발','방어','자살사고인정'...
|
||||
display_name TEXT NOT NULL,
|
||||
version INT NOT NULL DEFAULT 1,
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
UNIQUE (code, version)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS app.scale_def (
|
||||
scale_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
code TEXT NOT NULL UNIQUE, -- 'empathy','open_q','CTRS'...
|
||||
min_score REAL NOT NULL DEFAULT 0,
|
||||
max_score REAL NOT NULL DEFAULT 5, -- CTRS 는 0-6
|
||||
description TEXT
|
||||
);
|
||||
|
||||
|
||||
-- =============================================================================
|
||||
-- 3. 세션 (MASTERPLAN §3.2) — 케이스/회기 앵커
|
||||
-- case_profile 보다 먼저 정의(FK 의존: session_state → sessions).
|
||||
-- case_id 는 (persona_id, learner_id) 복합 인스턴스 식별(설계서 §3.5 핵심결정).
|
||||
-- =============================================================================
|
||||
CREATE TABLE IF NOT EXISTS app.sessions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
case_id UUID, -- 다회기 연속체(FK는 04에서 보강)
|
||||
learner_id UUID NOT NULL REFERENCES app.app_user(user_id),
|
||||
persona_id UUID, -- 시드 카드 persona_id
|
||||
persona_version INT, -- 핀된 카드 버전
|
||||
session_no INT, -- 케이스 내 N번째 회기
|
||||
theory_mode TEXT, -- 'humanistic'|'cbt'|'integrative'
|
||||
stage_path JSONB NOT NULL DEFAULT '[]', -- 단계 전이 이력
|
||||
started_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
ended_at TIMESTAMPTZ,
|
||||
FOREIGN KEY (persona_id, persona_version)
|
||||
REFERENCES app.persona_card(persona_id, version)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_case ON app.sessions(case_id, session_no);
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_learner ON app.sessions(learner_id);
|
||||
|
||||
|
||||
-- =============================================================================
|
||||
-- 4. 발화 (MASTERPLAN §3.2 + 설계서 §3.3 연속성 컬럼) — ② EPISODIC 원자단위
|
||||
-- append-only. PII 마스킹 후 적재. visible_to 는 표면이라 기본 전체 가시.
|
||||
-- =============================================================================
|
||||
CREATE TABLE IF NOT EXISTS app.turns (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
session_id UUID NOT NULL REFERENCES app.sessions(id) ON DELETE CASCADE,
|
||||
seq INT NOT NULL,
|
||||
speaker TEXT NOT NULL CHECK (speaker IN ('counselor','client')),
|
||||
stage TEXT REFERENCES app.stage_def(stage_code),
|
||||
text TEXT, -- 저장 전 Presidio PII 마스킹된 원문
|
||||
text_masked TEXT, -- 외부 LLM 전송용(추가 마스킹)
|
||||
actor_kind TEXT, -- 'client_ai'|'counselor_ai'|'evaluator_ai'|'human_learner'|'human_supervisor'
|
||||
llm_provider TEXT, model TEXT,
|
||||
tokens_in INT, tokens_out INT, cost_usd NUMERIC(12,6),
|
||||
visible_to TEXT[] NOT NULL DEFAULT '{client,counselor,evaluator}',
|
||||
-- 설계서 §3.3 연속성 컬럼
|
||||
salience REAL NOT NULL DEFAULT 0.0, -- 망각/회상 우선순위
|
||||
is_pinned BOOLEAN NOT NULL DEFAULT FALSE, -- pinned_fact 승격 발화
|
||||
contradicts UUID[] NOT NULL DEFAULT '{}', -- 뒤집은 과거 turn_id[]
|
||||
-- 음성 paralinguistic (MASTERPLAN_REVISIONS §3.5 선반영, nullable)
|
||||
audio_ref TEXT,
|
||||
silence_ms INT,
|
||||
speech_rate REAL,
|
||||
barge_in BOOLEAN,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (session_id, seq)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_turns_session ON app.turns(session_id, seq);
|
||||
CREATE INDEX IF NOT EXISTS idx_turns_salient
|
||||
ON app.turns(session_id) WHERE salience >= 0.5;
|
||||
|
||||
-- 발화 라벨 다대다 (복수 라벨)
|
||||
CREATE TABLE IF NOT EXISTS app.turn_technique (
|
||||
turn_id UUID NOT NULL REFERENCES app.turns(id) ON DELETE CASCADE,
|
||||
label_id BIGINT NOT NULL REFERENCES app.technique_label_def(label_id),
|
||||
PRIMARY KEY (turn_id, label_id)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS app.turn_client_state (
|
||||
turn_id UUID NOT NULL REFERENCES app.turns(id) ON DELETE CASCADE,
|
||||
label_id BIGINT NOT NULL REFERENCES app.client_state_def(label_id),
|
||||
PRIMARY KEY (turn_id, label_id)
|
||||
);
|
||||
|
||||
-- 슈퍼바이저 논평 (MASTERPLAN §3.2, 윤찬 "의도와 다른 부분" 1급 시민)
|
||||
CREATE TABLE IF NOT EXISTS app.supervisor_comment (
|
||||
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
turn_id UUID NOT NULL REFERENCES app.turns(id) ON DELETE CASCADE,
|
||||
kind TEXT NOT NULL CHECK (kind IN ('rationale','critique')),
|
||||
text TEXT NOT NULL,
|
||||
intent_deviation JSONB, -- {expected,actual,severity,dimension}
|
||||
visible_to TEXT[] NOT NULL DEFAULT '{evaluator}',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_sup_comment_turn ON app.supervisor_comment(turn_id);
|
||||
|
||||
-- 안전 이벤트 (MASTERPLAN §3.2)
|
||||
CREATE TABLE IF NOT EXISTS app.safety_events (
|
||||
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
session_id UUID REFERENCES app.sessions(id) ON DELETE CASCADE,
|
||||
turn_id UUID REFERENCES app.turns(id) ON DELETE SET NULL,
|
||||
trigger_type TEXT NOT NULL, -- 'suicide_means'|'real_crisis'|'ideation_cap'...
|
||||
ko_risk_level SMALLINT, -- 한국어 자살분류 1~5
|
||||
escalated BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
latency_ms INT,
|
||||
detail JSONB,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_safety_session ON app.safety_events(session_id);
|
||||
|
||||
|
||||
-- =============================================================================
|
||||
-- 5. 메모리 ① WORKING (설계서 §3.2) — 체크포인트
|
||||
-- 상태머신 결정론 수치 + 단기버퍼. 매 턴 UPSERT(프로세스 죽어도 복원).
|
||||
-- =============================================================================
|
||||
CREATE TABLE IF NOT EXISTS app.session_state (
|
||||
session_id UUID PRIMARY KEY REFERENCES app.sessions(id) ON DELETE CASCADE,
|
||||
stage TEXT NOT NULL DEFAULT '라포'
|
||||
CHECK (stage IN ('라포','탐색','개입','정리')),
|
||||
turn_seq INT NOT NULL DEFAULT 0,
|
||||
effective_openness REAL NOT NULL DEFAULT 0.15
|
||||
CHECK (effective_openness BETWEEN 0 AND 1),
|
||||
rapport_credit REAL NOT NULL DEFAULT 0.0, -- 회기말 0.7 이월 대상
|
||||
resistance REAL NOT NULL DEFAULT 0.65
|
||||
CHECK (resistance BETWEEN 0 AND 1),
|
||||
ideation_stage SMALLINT NOT NULL DEFAULT 1
|
||||
CHECK (ideation_stage BETWEEN 1 AND 5),
|
||||
affect_state JSONB NOT NULL DEFAULT '{}',
|
||||
short_buffer JSONB NOT NULL DEFAULT '[]', -- 최근 K턴 rolling window
|
||||
recall_context JSONB, -- 회기시작 1회 로드 캐시
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
|
||||
-- =============================================================================
|
||||
-- 6. 메모리 ② EPISODIC 임베딩 (설계서 §3.3) — BGE-M3 멀티벡터 + HNSW
|
||||
-- case_id/session_id 비정규화: 케이스 스코프 회상(cross-trainee 누수 차단).
|
||||
-- =============================================================================
|
||||
CREATE TABLE IF NOT EXISTS app.turn_embedding (
|
||||
turn_id UUID PRIMARY KEY REFERENCES app.turns(id) ON DELETE CASCADE,
|
||||
case_id UUID NOT NULL, -- denormalized 회상 스코프
|
||||
session_id UUID NOT NULL,
|
||||
seq INT NOT NULL,
|
||||
dense vector(1024) NOT NULL, -- BGE-M3 dense
|
||||
sparse JSONB, -- BGE-M3 sparse {token_id:weight}
|
||||
context_prefix TEXT, -- Contextual Retrieval 1문장
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_turn_emb_hnsw ON app.turn_embedding
|
||||
USING hnsw (dense vector_cosine_ops) WITH (m = 16, ef_construction = 64);
|
||||
CREATE INDEX IF NOT EXISTS idx_turn_emb_case ON app.turn_embedding (case_id, session_id);
|
||||
|
||||
|
||||
-- =============================================================================
|
||||
-- 7. 메모리 ③ SUMMARY (설계서 §3.4) — 회기당 1행
|
||||
-- (A) end_state 무손실 carry-over(코드복사) / (B) digest narrative(LLM) 분리.
|
||||
-- =============================================================================
|
||||
CREATE TABLE IF NOT EXISTS app.session_summary (
|
||||
session_id UUID PRIMARY KEY REFERENCES app.sessions(id) ON DELETE CASCADE,
|
||||
case_id UUID NOT NULL,
|
||||
session_no INT NOT NULL,
|
||||
-- (A) 무손실 carry-over [P2]
|
||||
end_state JSONB NOT NULL, -- {stage,openness,rapport_credit,...}
|
||||
rapport_delta REAL,
|
||||
-- (B) narrative 압축 (LLM, 손실 허용)
|
||||
digest TEXT NOT NULL, -- 6~10문장, 다음 회기 주입
|
||||
open_threads JSONB NOT NULL DEFAULT '[]', -- [{topic,last_stance,raised_session_no}]
|
||||
homework JSONB,
|
||||
emotional_arc TEXT,
|
||||
-- (C) 검색용
|
||||
digest_embedding vector(1024),
|
||||
token_count INT,
|
||||
compressed_by TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (case_id, session_no)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_sess_sum_case
|
||||
ON app.session_summary (case_id, session_no DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_sess_sum_emb ON app.session_summary
|
||||
USING hnsw (digest_embedding vector_cosine_ops) WITH (m = 16, ef_construction = 64);
|
||||
|
||||
|
||||
-- =============================================================================
|
||||
-- 8. 메모리 ④ SEMANTIC (설계서 §3.5) — case_profile(evolving) + pinned_fact
|
||||
-- 케이스 = (persona_id 템플릿) × (learner_id 인스턴스). 학습자별 독립 연속체.
|
||||
-- =============================================================================
|
||||
CREATE TABLE IF NOT EXISTS app.case_profile (
|
||||
case_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
persona_id UUID NOT NULL, -- 시드 페르소나(카드 persona_id)
|
||||
learner_id UUID NOT NULL REFERENCES app.app_user(user_id),
|
||||
last_session_no INT NOT NULL DEFAULT 0,
|
||||
ccd_estimate JSONB NOT NULL DEFAULT '{}', -- CCD 8요소 현재추정 +confidence
|
||||
presenting_arc JSONB NOT NULL DEFAULT '[]', -- 호소문제 궤적
|
||||
rapport_trajectory JSONB NOT NULL DEFAULT '[]', -- [{session_no,end_rapport,end_openness}]
|
||||
alliance_level REAL DEFAULT 0.2, -- 치료동맹 누적(EWMA)
|
||||
case_digest TEXT NOT NULL DEFAULT '', -- 전체 궤적 8~12문장(큰그림)
|
||||
digest_embedding vector(1024),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (persona_id, learner_id) -- 핵심 복합키(§3.5)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_case_learner ON app.case_profile(learner_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_case_emb ON app.case_profile
|
||||
USING hnsw (digest_embedding vector_cosine_ops) WITH (m = 16, ef_construction = 64);
|
||||
|
||||
-- pinned facts (람다 MEMORY.md, 절대 흘리면 안 되는 핵심, 모순 기준점)
|
||||
CREATE TABLE IF NOT EXISTS app.pinned_fact (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
case_id UUID NOT NULL REFERENCES app.case_profile(case_id) ON DELETE CASCADE,
|
||||
key TEXT NOT NULL, -- 'name','family',...
|
||||
value TEXT NOT NULL, -- 가명처리/마스킹된
|
||||
fact_type TEXT NOT NULL
|
||||
CHECK (fact_type IN ('identity','history','relationship','clinical','agreement')),
|
||||
status TEXT NOT NULL DEFAULT 'stable'
|
||||
CHECK (status IN ('stable','evolving','contradicted','locked')),
|
||||
source_turn UUID REFERENCES app.turns(id) ON DELETE SET NULL,
|
||||
confidence REAL NOT NULL DEFAULT 1.0,
|
||||
version INT NOT NULL DEFAULT 1,
|
||||
updated_session_no INT,
|
||||
visible_to TEXT[] NOT NULL DEFAULT '{client,evaluator}', -- 모순처리 정답신호
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (case_id, key)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_pinned_case ON app.pinned_fact (case_id, status);
|
||||
|
||||
-- pinned 변경 이력 (append-only, 모순/갱신 audit + 평가AI 정답신호)
|
||||
CREATE TABLE IF NOT EXISTS app.pinned_fact_history (
|
||||
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
fact_id UUID NOT NULL, -- pinned_fact 삭제돼도 이력 보존(FK 무)
|
||||
case_id UUID NOT NULL,
|
||||
old_value TEXT,
|
||||
new_value TEXT,
|
||||
reason TEXT, -- 'contradiction'|'clarification'|'progression'
|
||||
session_no INT,
|
||||
turn_id UUID,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_pinned_hist_fact ON app.pinned_fact_history(fact_id);
|
||||
|
||||
|
||||
-- =============================================================================
|
||||
-- 9. 지연 FK 보강 — sessions.case_id, summary/embedding case 정합
|
||||
-- case_profile 가 sessions 보다 뒤에 정의되므로 여기서 FK 연결.
|
||||
-- =============================================================================
|
||||
ALTER TABLE app.sessions
|
||||
DROP CONSTRAINT IF EXISTS fk_sessions_case;
|
||||
ALTER TABLE app.sessions
|
||||
ADD CONSTRAINT fk_sessions_case
|
||||
FOREIGN KEY (case_id) REFERENCES app.case_profile(case_id) ON DELETE SET NULL;
|
||||
104
infra/db/init/03_kb.sql
Normal file
104
infra/db/init/03_kb.sql
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
-- =============================================================================
|
||||
-- Vignette · 03_kb.sql
|
||||
-- kb 스키마: 정적 지식 KB (DSM/이론/technique/taxonomy) — app 과 물리 분리
|
||||
-- ⑤ KNOWLEDGE 계층. 3-AI 가 같은 물리 테이블을 다른 정책(visible_to+sensitivity)으로 검색.
|
||||
-- 근거: MEMORY_KNOWLEDGE_PERSONA_DESIGN.md §3.6, §4.3 / MASTERPLAN §3.6
|
||||
-- 전제: 02_schema.sql (app.technique_label_def 존재). 적용 순서: 02 → 03(본 파일).
|
||||
-- 원칙: app 으로의 FK 없음(read-only 독립). license_class C/D = 외부 LLM 차단.
|
||||
-- =============================================================================
|
||||
|
||||
CREATE SCHEMA IF NOT EXISTS kb;
|
||||
CREATE EXTENSION IF NOT EXISTS vector;
|
||||
CREATE EXTENSION IF NOT EXISTS pg_trgm;
|
||||
|
||||
SET search_path TO kb, public;
|
||||
|
||||
-- =============================================================================
|
||||
-- 1. 출처 + 라이선스 (DSM-5 저작권 1차 통제, 설계서 §3.6)
|
||||
-- =============================================================================
|
||||
CREATE TABLE IF NOT EXISTS kb.source (
|
||||
source_id TEXT PRIMARY KEY, -- 'dsm5','theory','taxonomy_0615'
|
||||
title TEXT NOT NULL,
|
||||
kb_kind TEXT NOT NULL CHECK (kb_kind IN
|
||||
('diagnostic','theory','technique','taxonomy',
|
||||
'supervisor_pattern','template','ko_context','microskill')),
|
||||
license_class CHAR(1) NOT NULL CHECK (license_class IN ('A','B','C','D')),
|
||||
-- C=저작권민감 D=미성년파생
|
||||
origin_path TEXT,
|
||||
citation TEXT,
|
||||
external_llm_ok BOOLEAN NOT NULL DEFAULT FALSE, -- C/D면 false → 국내 라우팅
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- =============================================================================
|
||||
-- 2. 문서 + 버전 (graphify content_hash 증분 인덱싱 차용)
|
||||
-- =============================================================================
|
||||
CREATE TABLE IF NOT EXISTS kb.document (
|
||||
doc_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
source_id TEXT NOT NULL REFERENCES kb.source(source_id),
|
||||
doc_uri TEXT NOT NULL,
|
||||
version INT NOT NULL DEFAULT 1,
|
||||
content_hash TEXT NOT NULL, -- SHA256 → 변경감지(증분)
|
||||
superseded_by BIGINT REFERENCES kb.document(doc_id),
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
indexed_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (source_id, doc_uri, version)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_kb_doc_active
|
||||
ON kb.document(source_id) WHERE is_active;
|
||||
|
||||
-- =============================================================================
|
||||
-- 3. 청크 + 멀티벡터 (검색 본체, 설계서 §3.6)
|
||||
-- visible_to + sensitivity 가 3-AI 정보비대칭을 DB WHERE 로 강제.
|
||||
-- =============================================================================
|
||||
CREATE TABLE IF NOT EXISTS kb.chunk (
|
||||
chunk_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
doc_id BIGINT NOT NULL REFERENCES kb.document(doc_id) ON DELETE CASCADE,
|
||||
source_id TEXT NOT NULL, -- 비정규화(필터 가속)
|
||||
kb_kind TEXT NOT NULL, -- 비정규화
|
||||
seq INT NOT NULL,
|
||||
heading_path TEXT,
|
||||
chunk_text TEXT NOT NULL, -- 표시·LLM 주입(prefix 미포함)
|
||||
context_prefix TEXT, -- Contextual Retrieval 주입문
|
||||
embedding vector(1024), -- BGE-M3 dense
|
||||
sparse_vec JSONB, -- BGE-M3 sparse
|
||||
colbert_vecs JSONB, -- 멀티벡터(단일 호출 산출)
|
||||
visible_to TEXT[] NOT NULL DEFAULT '{client,counselor,evaluator}',
|
||||
sensitivity SMALLINT NOT NULL DEFAULT 0
|
||||
CHECK (sensitivity BETWEEN 0 AND 3), -- 0공개 1내부 2평가전용 3원천격리
|
||||
label_id BIGINT REFERENCES app.technique_label_def(label_id), -- taxonomy 정답라벨
|
||||
meta JSONB DEFAULT '{}', -- {dsm_category,theory,bias_weight,...}
|
||||
token_count INT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
-- dense ANN
|
||||
CREATE INDEX IF NOT EXISTS idx_chunk_hnsw ON kb.chunk
|
||||
USING hnsw (embedding vector_cosine_ops) WITH (m = 16, ef_construction = 64);
|
||||
-- sparse/BM25 (한국어: simple + pg_trgm 병용, M10 완화)
|
||||
CREATE INDEX IF NOT EXISTS idx_chunk_fts ON kb.chunk
|
||||
USING gin (to_tsvector('simple', chunk_text));
|
||||
CREATE INDEX IF NOT EXISTS idx_chunk_trgm ON kb.chunk
|
||||
USING gin (chunk_text gin_trgm_ops);
|
||||
-- 정보비대칭 사전필터
|
||||
CREATE INDEX IF NOT EXISTS idx_chunk_visible ON kb.chunk USING gin (visible_to);
|
||||
CREATE INDEX IF NOT EXISTS idx_chunk_route ON kb.chunk (kb_kind, sensitivity);
|
||||
|
||||
-- =============================================================================
|
||||
-- 4. KB 검색 로그 (환각측정·캐시검증, 람다 cost.json 차용, 설계서 §3.7)
|
||||
-- =============================================================================
|
||||
CREATE TABLE IF NOT EXISTS kb.retrieval_log (
|
||||
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
session_id UUID,
|
||||
turn_id UUID,
|
||||
ai_role TEXT NOT NULL, -- 'client'|'counselor'|'evaluator'
|
||||
query_text TEXT,
|
||||
policy TEXT NOT NULL, -- 4-튜플 정책명
|
||||
hit_chunk_ids BIGINT[],
|
||||
rerank_scores REAL[],
|
||||
top1_score REAL, -- CRAG 게이트
|
||||
used_in_answer BOOLEAN,
|
||||
latency_ms INT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_kb_retr_session ON kb.retrieval_log(session_id);
|
||||
246
infra/db/init/04_audit_eval_rls.sql
Normal file
246
infra/db/init/04_audit_eval_rls.sql
Normal file
|
|
@ -0,0 +1,246 @@
|
|||
-- =============================================================================
|
||||
-- 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-튜플) 로 컨텍스트를 명시 주입한다.
|
||||
Loading…
Add table
Add a link
Reference in a new issue