vignette/infra/db/init/02_schema.sql

415 lines
22 KiB
SQL
Raw Permalink 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 · 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,
account_status TEXT NOT NULL DEFAULT 'approved'
CHECK (account_status IN ('pending','approved','suspended')),
admin_access BOOLEAN NOT NULL DEFAULT FALSE,
learner_feedback_enabled BOOLEAN NOT NULL DEFAULT TRUE,
affiliation TEXT NOT NULL DEFAULT '',
legal_name TEXT NOT NULL DEFAULT '',
department TEXT NOT NULL DEFAULT '',
grade_level TEXT NOT NULL DEFAULT '',
phone TEXT NOT NULL DEFAULT '',
contact_address TEXT NOT NULL DEFAULT '',
nickname TEXT NOT NULL DEFAULT '',
self_introduction TEXT NOT NULL DEFAULT '',
avatar_url TEXT NOT NULL DEFAULT '',
consent_at TIMESTAMPTZ, -- 사전동의 하드게이트(IRB)
profile_completed_at TIMESTAMPTZ,
terms_agreed_at TIMESTAMPTZ,
privacy_agreed_at TIMESTAMPTZ,
terms_version TEXT NOT NULL DEFAULT '',
privacy_version TEXT NOT NULL DEFAULT '',
last_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_app_user_cohort ON app.app_user(cohort);
CREATE INDEX IF NOT EXISTS idx_app_user_account_status
ON app.app_user(account_status, last_seen_at DESC);
-- =============================================================================
-- 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 (진단명 비노출)
triggers JSONB NOT NULL DEFAULT '{}'::jsonb, -- 역린·상담자 금기·강한 반응 양상
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 '[]', -- 단계 전이 이력
session_goals JSONB NOT NULL DEFAULT '[]', -- 이번 회기 목표 단계(학습자 선택, 최대 4)
learner_feedback_enabled BOOLEAN NOT NULL DEFAULT TRUE, -- 회기 시작 시 계정 정책 스냅샷
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);
-- 교수자 회기 검토 상태. 임상 루브릭/채점과 분리된 회기 단위 작업 상태다.
CREATE TABLE IF NOT EXISTS app.session_review_status (
session_id UUID PRIMARY KEY REFERENCES app.sessions(id) ON DELETE CASCADE,
reviewer_id UUID REFERENCES app.app_user(user_id),
status TEXT NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending','viewed','closed')),
note TEXT NOT NULL DEFAULT '',
worksheet_status TEXT NOT NULL DEFAULT 'pending',
worksheet_note TEXT NOT NULL DEFAULT '',
worksheet_reviewed_at TIMESTAMPTZ,
reviewed_at TIMESTAMPTZ,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_session_review_status_status
ON app.session_review_status(status, updated_at DESC);
-- =============================================================================
-- 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,
provider_events JSONB NOT NULL DEFAULT '[]'::jsonb,
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()
);
CREATE INDEX IF NOT EXISTS idx_case_profile_learner_persona_activity
ON app.case_profile (learner_id, persona_id, updated_at DESC, case_id);
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;