-- ============================================================================= -- 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;