G0~G8 성과·동맹 측정 OS 작업 일괄 고정
8월 7일까지 워킹트리에만 남아 있던 미커밋 작업을 커밋한다. 여러 사본 폴더(worktree·clone)에 흩어져 있던 중간 스냅샷을 정리하기 전에 원본을 git 이력으로 고정하는 것이 목적이다. - contracts/routes/services: measurement, outcome_trajectory, rupture_repair, deliberate_practice, calibration_transfer, supervision_research, multimodal_alliance, continuous_improvement 계열 신규 모듈과 테스트 - infra/db/init: 07~16 마이그레이션(측정 기반~calibration transfer 실행) - apps/web: 세션 리뷰 카드·관리 화면·E2E 스펙 추가 - docs/ops: G0~G8 라이브 통합·배포·롤백 증거 문서와 evidence JSON/PNG - scripts: smoke·ledger·릴리스 에이전트·NAS 프리뷰 운영 스크립트 engine.public 로그 .bak과 apps/web/test-results 산출물은 커밋에서 제외했다.
This commit is contained in:
parent
93dd8f82d7
commit
16e791e044
390 changed files with 243188 additions and 499 deletions
765
infra/db/init/07_measurement_foundation.sql
Normal file
765
infra/db/init/07_measurement_foundation.sql
Normal file
|
|
@ -0,0 +1,765 @@
|
|||
-- Outcome & Alliance OS G0: provenance-first append-only measurement ledger.
|
||||
-- Prerequisites: 02_schema.sql, 04_audit_eval_rls.sql, 06_session_evaluation.sql.
|
||||
|
||||
CREATE SCHEMA IF NOT EXISTS audit;
|
||||
CREATE SCHEMA IF NOT EXISTS ds;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS app.measurement_instrument (
|
||||
instrument_id TEXT NOT NULL,
|
||||
instrument_version TEXT NOT NULL,
|
||||
name_ko TEXT NOT NULL,
|
||||
instrument_kind TEXT NOT NULL CHECK (
|
||||
instrument_kind IN ('validated_measure','training_metric','simulation_signal','runtime_metric')
|
||||
),
|
||||
construct TEXT NOT NULL CHECK (
|
||||
construct IN (
|
||||
'working_alliance','session_outcome','rupture_repair','counselor_skill',
|
||||
'self_calibration','transfer','simulation_progress'
|
||||
)
|
||||
),
|
||||
language TEXT NOT NULL DEFAULT 'ko-KR',
|
||||
license_id TEXT,
|
||||
validation_basis TEXT NOT NULL,
|
||||
scoring_schema JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (instrument_id, instrument_version)
|
||||
);
|
||||
|
||||
INSERT INTO app.measurement_instrument (
|
||||
instrument_id, instrument_version, name_ko, instrument_kind, construct,
|
||||
validation_basis, scoring_schema, metadata
|
||||
) VALUES
|
||||
(
|
||||
'vignette-state-machine', 'legacy-1', '기존 상태머신 라포 누적',
|
||||
'simulation_signal', 'simulation_progress',
|
||||
'결정론적 시뮬레이션 진행 신호. 치료동맹 척도가 아님.',
|
||||
'{"min":0,"max":1}'::jsonb,
|
||||
'{"clinical_claim_allowed":false,"legacy_signal":"session_state.rapport_credit"}'::jsonb
|
||||
),
|
||||
(
|
||||
'vignette-alliance-ewma', 'legacy-1', '기존 라포 EWMA',
|
||||
'simulation_signal', 'simulation_progress',
|
||||
'rapport_credit 회기말 값의 EWMA. Working Alliance 척도가 아님.',
|
||||
'{"min":0,"max":1}'::jsonb,
|
||||
'{"clinical_claim_allowed":false,"legacy_signal":"case_profile.alliance_level"}'::jsonb
|
||||
),
|
||||
(
|
||||
'vignette-fast-evaluator', 'legacy-1', '기존 턴 평가기',
|
||||
'training_metric', 'counselor_skill',
|
||||
'모델 기반 경량 훈련 피드백. 인간 평정과 동등하지 않음.',
|
||||
'{"appropriateness":{"warn":0.25,"neutral":0.5,"pos":1.0},"rapport_signal":{"min":-1,"max":1}}'::jsonb,
|
||||
'{"clinical_claim_allowed":false,"loop":"fast"}'::jsonb
|
||||
),
|
||||
(
|
||||
'vignette-deep-evaluator', 'legacy-1', '기존 회기 평가기',
|
||||
'training_metric', 'counselor_skill',
|
||||
'모델 태그와 정성 코칭. 독립 심리측정 척도가 아님.',
|
||||
'{"technique_occurrence_count":{"min":0,"max":1000}}'::jsonb,
|
||||
'{"clinical_claim_allowed":false,"loop":"deep"}'::jsonb
|
||||
),
|
||||
(
|
||||
'phase3-prepost', 'design-1', 'Phase 3 사전사후 자기보고',
|
||||
'training_metric', 'self_calibration',
|
||||
'공식 문항과 한국어 타당화 확정 전 파일럿 자기보고 설계.',
|
||||
'{"min":0,"max":1}'::jsonb,
|
||||
'{"clinical_claim_allowed":false,"status":"design_pending"}'::jsonb
|
||||
),
|
||||
(
|
||||
'phase3-runtime-kpi', 'design-1', 'Phase 3 운영 KPI',
|
||||
'runtime_metric', 'transfer',
|
||||
'완주·검수·IAA 등 시스템 및 연구 운영 증거.',
|
||||
'{"min":0,"max":1}'::jsonb,
|
||||
'{"clinical_claim_allowed":false}'::jsonb
|
||||
),
|
||||
(
|
||||
'alliance-pulse-learner', '1.0.0', '동맹 펄스 학습자 자기평가',
|
||||
'training_metric', 'working_alliance',
|
||||
'수련생이 외부 관점 공개 전에 잠그는 goal/task/bond 자기평가.',
|
||||
'{"dimensions":["goal","task","bond"],"min":0,"max":1}'::jsonb,
|
||||
'{"clinical_claim_allowed":false,"perspective":"learner_self_report"}'::jsonb
|
||||
),
|
||||
(
|
||||
'alliance-pulse-client-agent', '1.0.0', '동맹 펄스 가상내담자 보고',
|
||||
'training_metric', 'working_alliance',
|
||||
'가상내담자 에이전트가 대화 근거로 보고한 수용 경험. 실제 내담자 보고가 아님.',
|
||||
'{"dimensions":["goal","task","bond"],"min":0,"max":1}'::jsonb,
|
||||
'{"clinical_claim_allowed":false,"perspective":"client_agent_report"}'::jsonb
|
||||
),
|
||||
(
|
||||
'alliance-pulse-observer', '1.0.0', '동맹 펄스 독립 관찰자 추론',
|
||||
'training_metric', 'working_alliance',
|
||||
'내부 페르소나 상태 없이 마스킹 축어록만 보는 독립 평가 모델의 추론.',
|
||||
'{"dimensions":["goal","task","bond"],"min":0,"max":1}'::jsonb,
|
||||
'{"clinical_claim_allowed":false,"perspective":"independent_observer"}'::jsonb
|
||||
),
|
||||
(
|
||||
'alliance-pulse-supervisor', '1.0.0', '동맹 펄스 교수자 재평정',
|
||||
'training_metric', 'working_alliance',
|
||||
'교수자 수동 근거 평정. 기존 이벤트를 수정하지 않고 새 버전으로 supersede한다.',
|
||||
'{"dimensions":["goal","task","bond"],"min":0,"max":1}'::jsonb,
|
||||
'{"clinical_claim_allowed":false,"perspective":"supervisor_human"}'::jsonb
|
||||
)
|
||||
ON CONFLICT (instrument_id, instrument_version) DO NOTHING;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS audit.model_run (
|
||||
model_run_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
session_id UUID,
|
||||
turn_id UUID,
|
||||
agent_role TEXT NOT NULL CHECK (
|
||||
agent_role IN ('client','evaluator','coach','scenario','research')
|
||||
),
|
||||
provider TEXT NOT NULL,
|
||||
model TEXT NOT NULL,
|
||||
prompt_bundle_id TEXT NOT NULL,
|
||||
prompt_bundle_version TEXT NOT NULL,
|
||||
prompt_bundle_hash TEXT NOT NULL CHECK (prompt_bundle_hash ~ '^[a-f0-9]{64}$'),
|
||||
structured_schema_version TEXT NOT NULL,
|
||||
input_evidence_hash TEXT NOT NULL CHECK (input_evidence_hash ~ '^[a-f0-9]{64}$'),
|
||||
status TEXT NOT NULL CHECK (status IN ('ready','degraded','error')),
|
||||
error_code TEXT,
|
||||
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
CHECK (
|
||||
(status = 'error' AND error_code IS NOT NULL)
|
||||
OR (status <> 'error')
|
||||
),
|
||||
CHECK (status <> 'ready' OR error_code IS NULL)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_model_run_session_created
|
||||
ON audit.model_run(session_id, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_model_run_model_created
|
||||
ON audit.model_run(provider, model, created_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS app.measurement_event (
|
||||
measurement_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
session_id UUID NOT NULL REFERENCES app.sessions(id) ON DELETE RESTRICT,
|
||||
turn_id UUID REFERENCES app.turns(id) ON DELETE SET NULL,
|
||||
supersedes_id UUID REFERENCES app.measurement_event(measurement_id) ON DELETE RESTRICT,
|
||||
construct TEXT NOT NULL CHECK (
|
||||
construct IN (
|
||||
'working_alliance','session_outcome','rupture_repair','counselor_skill',
|
||||
'self_calibration','transfer','simulation_progress'
|
||||
)
|
||||
),
|
||||
dimension TEXT NOT NULL CHECK (length(btrim(dimension)) > 0),
|
||||
perspective TEXT NOT NULL CHECK (
|
||||
perspective IN (
|
||||
'client_agent_report','learner_self_report','independent_observer',
|
||||
'supervisor_human','client_simulation','runtime_observation'
|
||||
)
|
||||
),
|
||||
source_kind TEXT NOT NULL CHECK (
|
||||
source_kind IN (
|
||||
'simulated_state','model_inferred','agent_reported',
|
||||
'learner_reported','human_rated','observed_runtime'
|
||||
)
|
||||
),
|
||||
instrument_id TEXT NOT NULL,
|
||||
instrument_version TEXT NOT NULL,
|
||||
value DOUBLE PRECISION,
|
||||
scale_min DOUBLE PRECISION NOT NULL,
|
||||
scale_max DOUBLE PRECISION NOT NULL,
|
||||
confidence DOUBLE PRECISION CHECK (confidence BETWEEN 0 AND 1),
|
||||
status TEXT NOT NULL DEFAULT 'ready'
|
||||
CHECK (status IN ('ready','degraded','error','rejected')),
|
||||
error_code TEXT,
|
||||
evidence_turn_ids UUID[] NOT NULL DEFAULT '{}',
|
||||
model_run_id UUID REFERENCES audit.model_run(model_run_id) ON DELETE RESTRICT,
|
||||
visible_to TEXT[] NOT NULL DEFAULT '{evaluator}',
|
||||
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
FOREIGN KEY (instrument_id, instrument_version)
|
||||
REFERENCES app.measurement_instrument(instrument_id, instrument_version),
|
||||
CHECK (scale_max > scale_min),
|
||||
CHECK (value IS NULL OR value BETWEEN scale_min AND scale_max),
|
||||
CHECK (measurement_id IS DISTINCT FROM supersedes_id),
|
||||
CHECK (cardinality(visible_to) > 0),
|
||||
CHECK (visible_to <@ ARRAY['client','counselor','evaluator','supervisor','research']::TEXT[]),
|
||||
CHECK (
|
||||
(source_kind = 'simulated_state' AND perspective = 'client_simulation')
|
||||
OR (source_kind = 'model_inferred' AND perspective = 'independent_observer')
|
||||
OR (source_kind = 'agent_reported' AND perspective = 'client_agent_report')
|
||||
OR (source_kind = 'learner_reported' AND perspective = 'learner_self_report')
|
||||
OR (source_kind = 'human_rated' AND perspective = 'supervisor_human')
|
||||
OR (source_kind = 'observed_runtime' AND perspective = 'runtime_observation')
|
||||
),
|
||||
CHECK (source_kind NOT IN ('model_inferred','agent_reported') OR model_run_id IS NOT NULL),
|
||||
CHECK (status <> 'ready' OR (value IS NOT NULL AND error_code IS NULL)),
|
||||
CHECK (status NOT IN ('error','rejected') OR (value IS NULL AND error_code IS NOT NULL))
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_measurement_event_supersedes_once
|
||||
ON app.measurement_event(supersedes_id)
|
||||
WHERE supersedes_id IS NOT NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_measurement_event_session_created
|
||||
ON app.measurement_event(session_id, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_measurement_event_session_construct
|
||||
ON app.measurement_event(session_id, construct, dimension, perspective, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_measurement_event_model_run
|
||||
ON app.measurement_event(model_run_id)
|
||||
WHERE model_run_id IS NOT NULL;
|
||||
|
||||
CREATE OR REPLACE FUNCTION audit.reject_measurement_mutation()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
BEGIN
|
||||
RAISE EXCEPTION '% is append-only; write a new event with supersedes_id', TG_TABLE_NAME
|
||||
USING ERRCODE = '55000';
|
||||
END;
|
||||
$$;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_measurement_event_append_only ON app.measurement_event;
|
||||
CREATE TRIGGER trg_measurement_event_append_only
|
||||
BEFORE UPDATE OR DELETE ON app.measurement_event
|
||||
FOR EACH ROW EXECUTE FUNCTION audit.reject_measurement_mutation();
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_model_run_append_only ON audit.model_run;
|
||||
CREATE TRIGGER trg_model_run_append_only
|
||||
BEFORE UPDATE OR DELETE ON audit.model_run
|
||||
FOR EACH ROW EXECUTE FUNCTION audit.reject_measurement_mutation();
|
||||
|
||||
CREATE TABLE IF NOT EXISTS app.alliance_pulse (
|
||||
pulse_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
session_id UUID NOT NULL REFERENCES app.sessions(id) ON DELETE RESTRICT,
|
||||
checkpoint TEXT NOT NULL CHECK (checkpoint IN ('pre','mid','post')),
|
||||
status TEXT NOT NULL DEFAULT 'awaiting_agents'
|
||||
CHECK (status IN ('awaiting_agents','ready','degraded','error')),
|
||||
learner_locked_at TIMESTAMPTZ NOT NULL,
|
||||
revealed_at TIMESTAMPTZ,
|
||||
error_code TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (session_id, checkpoint),
|
||||
CONSTRAINT ck_alliance_pulse_lock_before_reveal CHECK (
|
||||
revealed_at IS NULL OR revealed_at >= learner_locked_at
|
||||
),
|
||||
CONSTRAINT ck_alliance_pulse_time_order CHECK (
|
||||
created_at <= learner_locked_at
|
||||
AND updated_at >= created_at
|
||||
AND (revealed_at IS NULL OR updated_at >= revealed_at)
|
||||
),
|
||||
CONSTRAINT ck_alliance_pulse_terminal_state CHECK (
|
||||
(
|
||||
status = 'awaiting_agents'
|
||||
AND revealed_at IS NULL
|
||||
AND error_code IS NULL
|
||||
)
|
||||
OR (
|
||||
status = 'ready'
|
||||
AND revealed_at IS NOT NULL
|
||||
AND error_code IS NULL
|
||||
)
|
||||
OR (
|
||||
status = 'degraded'
|
||||
AND revealed_at IS NOT NULL
|
||||
)
|
||||
OR (
|
||||
status = 'error'
|
||||
AND revealed_at IS NOT NULL
|
||||
AND error_code IS NOT NULL
|
||||
)
|
||||
)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_alliance_pulse_session_created
|
||||
ON app.alliance_pulse(session_id, created_at DESC);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_alliance_pulse_identity
|
||||
ON app.alliance_pulse(pulse_id, session_id);
|
||||
|
||||
-- CREATE TABLE IF NOT EXISTS만으로는 기존 개발 DB에 새 CHECK가 보강되지 않으므로
|
||||
-- 이름이 있는 제약을 멱등 추가하고 즉시 검증한다. 위반 데이터가 있으면 fail-closed한다.
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint
|
||||
WHERE conname = 'ck_alliance_pulse_lock_before_reveal'
|
||||
AND conrelid = 'app.alliance_pulse'::regclass
|
||||
) THEN
|
||||
ALTER TABLE app.alliance_pulse
|
||||
ADD CONSTRAINT ck_alliance_pulse_lock_before_reveal CHECK (
|
||||
revealed_at IS NULL OR revealed_at >= learner_locked_at
|
||||
) NOT VALID;
|
||||
ALTER TABLE app.alliance_pulse
|
||||
VALIDATE CONSTRAINT ck_alliance_pulse_lock_before_reveal;
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint
|
||||
WHERE conname = 'ck_alliance_pulse_time_order'
|
||||
AND conrelid = 'app.alliance_pulse'::regclass
|
||||
) THEN
|
||||
ALTER TABLE app.alliance_pulse
|
||||
ADD CONSTRAINT ck_alliance_pulse_time_order CHECK (
|
||||
created_at <= learner_locked_at
|
||||
AND updated_at >= created_at
|
||||
AND (revealed_at IS NULL OR updated_at >= revealed_at)
|
||||
) NOT VALID;
|
||||
ALTER TABLE app.alliance_pulse
|
||||
VALIDATE CONSTRAINT ck_alliance_pulse_time_order;
|
||||
END IF;
|
||||
|
||||
-- degraded는 측정값을 보간하지 않으면서 원인 코드를 노출해야 한다. 초기
|
||||
-- 제약은 ready와 degraded를 한 분기로 묶어 서비스의 fail-closed 결과를
|
||||
-- 거부했으므로, 기존 DB에서도 정의가 교체되도록 이름만 확인하지 않는다.
|
||||
ALTER TABLE app.alliance_pulse
|
||||
DROP CONSTRAINT IF EXISTS ck_alliance_pulse_terminal_state;
|
||||
ALTER TABLE app.alliance_pulse
|
||||
ADD CONSTRAINT ck_alliance_pulse_terminal_state CHECK (
|
||||
(
|
||||
status = 'awaiting_agents'
|
||||
AND revealed_at IS NULL
|
||||
AND error_code IS NULL
|
||||
)
|
||||
OR (
|
||||
status = 'ready'
|
||||
AND revealed_at IS NOT NULL
|
||||
AND error_code IS NULL
|
||||
)
|
||||
OR (
|
||||
status = 'degraded'
|
||||
AND revealed_at IS NOT NULL
|
||||
)
|
||||
OR (
|
||||
status = 'error'
|
||||
AND revealed_at IS NOT NULL
|
||||
AND error_code IS NOT NULL
|
||||
)
|
||||
) NOT VALID;
|
||||
ALTER TABLE app.alliance_pulse
|
||||
VALIDATE CONSTRAINT ck_alliance_pulse_terminal_state;
|
||||
END;
|
||||
$$;
|
||||
|
||||
ALTER TABLE app.measurement_event
|
||||
ADD COLUMN IF NOT EXISTS pulse_id UUID;
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint
|
||||
WHERE conname = 'measurement_event_pulse_id_fkey'
|
||||
AND conrelid = 'app.measurement_event'::regclass
|
||||
) THEN
|
||||
ALTER TABLE app.measurement_event
|
||||
ADD CONSTRAINT measurement_event_pulse_id_fkey
|
||||
FOREIGN KEY (pulse_id) REFERENCES app.alliance_pulse(pulse_id) ON DELETE RESTRICT;
|
||||
END IF;
|
||||
END;
|
||||
$$;
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint
|
||||
WHERE conname = 'measurement_event_pulse_session_fkey'
|
||||
AND conrelid = 'app.measurement_event'::regclass
|
||||
) THEN
|
||||
ALTER TABLE app.measurement_event
|
||||
ADD CONSTRAINT measurement_event_pulse_session_fkey
|
||||
FOREIGN KEY (pulse_id, session_id)
|
||||
REFERENCES app.alliance_pulse(pulse_id, session_id) ON DELETE RESTRICT;
|
||||
END IF;
|
||||
END;
|
||||
$$;
|
||||
CREATE INDEX IF NOT EXISTS idx_measurement_event_pulse_created
|
||||
ON app.measurement_event(pulse_id, created_at DESC)
|
||||
WHERE pulse_id IS NOT NULL;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS app.self_assessment (
|
||||
self_assessment_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
pulse_id UUID NOT NULL UNIQUE,
|
||||
session_id UUID NOT NULL REFERENCES app.sessions(id) ON DELETE RESTRICT,
|
||||
learner_id UUID NOT NULL REFERENCES app.app_user(user_id) ON DELETE RESTRICT,
|
||||
scores JSONB NOT NULL CHECK (
|
||||
jsonb_typeof(scores) = 'object'
|
||||
AND scores ?& ARRAY['goal','task','bond']
|
||||
AND scores - ARRAY['goal','task','bond'] = '{}'::jsonb
|
||||
AND (scores->>'goal')::double precision BETWEEN 0 AND 1
|
||||
AND (scores->>'task')::double precision BETWEEN 0 AND 1
|
||||
AND (scores->>'bond')::double precision BETWEEN 0 AND 1
|
||||
),
|
||||
evidence_turn_ids UUID[] NOT NULL DEFAULT '{}',
|
||||
locked_at TIMESTAMPTZ NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
CONSTRAINT self_assessment_pulse_session_fkey
|
||||
FOREIGN KEY (pulse_id, session_id)
|
||||
REFERENCES app.alliance_pulse(pulse_id, session_id) ON DELETE RESTRICT,
|
||||
CONSTRAINT ck_self_assessment_lock_time CHECK (locked_at >= created_at)
|
||||
);
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint
|
||||
WHERE conname = 'self_assessment_pulse_session_fkey'
|
||||
AND conrelid = 'app.self_assessment'::regclass
|
||||
) THEN
|
||||
ALTER TABLE app.self_assessment
|
||||
ADD CONSTRAINT self_assessment_pulse_session_fkey
|
||||
FOREIGN KEY (pulse_id, session_id)
|
||||
REFERENCES app.alliance_pulse(pulse_id, session_id) ON DELETE RESTRICT;
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint
|
||||
WHERE conname = 'ck_self_assessment_lock_time'
|
||||
AND conrelid = 'app.self_assessment'::regclass
|
||||
) THEN
|
||||
ALTER TABLE app.self_assessment
|
||||
ADD CONSTRAINT ck_self_assessment_lock_time
|
||||
CHECK (locked_at >= created_at) NOT VALID;
|
||||
ALTER TABLE app.self_assessment
|
||||
VALIDATE CONSTRAINT ck_self_assessment_lock_time;
|
||||
END IF;
|
||||
END;
|
||||
$$;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_self_assessment_append_only ON app.self_assessment;
|
||||
CREATE TRIGGER trg_self_assessment_append_only
|
||||
BEFORE UPDATE OR DELETE ON app.self_assessment
|
||||
FOR EACH ROW EXECUTE FUNCTION audit.reject_measurement_mutation();
|
||||
|
||||
-- alliance_pulse는 비동기 에이전트 완료 시 상태만 한 번 전이한다. 잠금 시점과
|
||||
-- 회기/체크포인트는 불변이며, 상태 변경 자체는 별도 append-only 감사 이벤트로 남긴다.
|
||||
CREATE TABLE IF NOT EXISTS audit.alliance_pulse_status_event (
|
||||
status_event_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
pulse_id UUID NOT NULL REFERENCES app.alliance_pulse(pulse_id) ON DELETE RESTRICT,
|
||||
from_status TEXT,
|
||||
to_status TEXT NOT NULL,
|
||||
error_code TEXT,
|
||||
revealed_at TIMESTAMPTZ,
|
||||
changed_by_uid UUID,
|
||||
changed_by_role TEXT NOT NULL,
|
||||
ai_view TEXT,
|
||||
changed_at TIMESTAMPTZ NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (pulse_id, changed_at, to_status),
|
||||
CHECK (
|
||||
(from_status IS NULL AND to_status = 'awaiting_agents')
|
||||
OR (
|
||||
from_status = 'awaiting_agents'
|
||||
AND to_status IN ('ready','degraded','error')
|
||||
)
|
||||
),
|
||||
CONSTRAINT ck_alliance_status_event_error_required CHECK (
|
||||
to_status NOT IN ('degraded','error') OR error_code IS NOT NULL
|
||||
),
|
||||
CONSTRAINT ck_alliance_status_event_error_forbidden CHECK (
|
||||
to_status IN ('degraded','error') OR error_code IS NULL
|
||||
),
|
||||
CHECK (to_status = 'awaiting_agents' OR revealed_at IS NOT NULL)
|
||||
);
|
||||
|
||||
-- 기존 개발/운영 DB의 unnamed check1/check2도 교체한다. pulse가 degraded
|
||||
-- 원인을 가진 채 terminal audit event를 남기는 현재 서비스 계약과 맞춘다.
|
||||
ALTER TABLE audit.alliance_pulse_status_event
|
||||
DROP CONSTRAINT IF EXISTS alliance_pulse_status_event_check1;
|
||||
ALTER TABLE audit.alliance_pulse_status_event
|
||||
DROP CONSTRAINT IF EXISTS alliance_pulse_status_event_check2;
|
||||
ALTER TABLE audit.alliance_pulse_status_event
|
||||
DROP CONSTRAINT IF EXISTS ck_alliance_status_event_error_required;
|
||||
ALTER TABLE audit.alliance_pulse_status_event
|
||||
DROP CONSTRAINT IF EXISTS ck_alliance_status_event_error_forbidden;
|
||||
ALTER TABLE audit.alliance_pulse_status_event
|
||||
ADD CONSTRAINT ck_alliance_status_event_error_required CHECK (
|
||||
to_status NOT IN ('degraded','error') OR error_code IS NOT NULL
|
||||
) NOT VALID;
|
||||
ALTER TABLE audit.alliance_pulse_status_event
|
||||
ADD CONSTRAINT ck_alliance_status_event_error_forbidden CHECK (
|
||||
to_status IN ('degraded','error') OR error_code IS NULL
|
||||
) NOT VALID;
|
||||
ALTER TABLE audit.alliance_pulse_status_event
|
||||
VALIDATE CONSTRAINT ck_alliance_status_event_error_required;
|
||||
ALTER TABLE audit.alliance_pulse_status_event
|
||||
VALIDATE CONSTRAINT ck_alliance_status_event_error_forbidden;
|
||||
CREATE INDEX IF NOT EXISTS idx_alliance_pulse_status_event_pulse_created
|
||||
ON audit.alliance_pulse_status_event(pulse_id, changed_at DESC);
|
||||
|
||||
CREATE OR REPLACE FUNCTION audit.enforce_alliance_pulse_transition()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
BEGIN
|
||||
IF NEW.pulse_id IS DISTINCT FROM OLD.pulse_id
|
||||
OR NEW.session_id IS DISTINCT FROM OLD.session_id
|
||||
OR NEW.checkpoint IS DISTINCT FROM OLD.checkpoint
|
||||
OR NEW.learner_locked_at IS DISTINCT FROM OLD.learner_locked_at
|
||||
OR NEW.created_at IS DISTINCT FROM OLD.created_at THEN
|
||||
RAISE EXCEPTION 'alliance_pulse identity and learner lock are immutable'
|
||||
USING ERRCODE = '55000';
|
||||
END IF;
|
||||
|
||||
IF OLD.status <> 'awaiting_agents'
|
||||
OR NEW.status NOT IN ('ready','degraded','error') THEN
|
||||
RAISE EXCEPTION 'alliance_pulse permits one transition from awaiting_agents to a terminal state'
|
||||
USING ERRCODE = '55000';
|
||||
END IF;
|
||||
|
||||
IF NEW.updated_at < OLD.updated_at
|
||||
OR NEW.revealed_at IS NULL
|
||||
OR NEW.revealed_at < OLD.learner_locked_at
|
||||
OR NEW.updated_at < NEW.revealed_at THEN
|
||||
RAISE EXCEPTION 'alliance_pulse reveal must follow the learner lock monotonically'
|
||||
USING ERRCODE = '55000';
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION audit.log_alliance_pulse_status()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = pg_catalog, audit, app
|
||||
AS $$
|
||||
BEGIN
|
||||
INSERT INTO audit.alliance_pulse_status_event (
|
||||
pulse_id, from_status, to_status, error_code, revealed_at,
|
||||
changed_by_uid, changed_by_role, ai_view, changed_at
|
||||
) VALUES (
|
||||
NEW.pulse_id,
|
||||
CASE WHEN TG_OP = 'INSERT' THEN NULL ELSE OLD.status END,
|
||||
NEW.status,
|
||||
NEW.error_code,
|
||||
NEW.revealed_at,
|
||||
app.current_uid(),
|
||||
app.current_role_name(),
|
||||
NULLIF(current_setting('app.current_ai_view', true), ''),
|
||||
CASE WHEN TG_OP = 'INSERT' THEN NEW.created_at ELSE NEW.updated_at END
|
||||
)
|
||||
ON CONFLICT (pulse_id, changed_at, to_status) DO NOTHING;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_alliance_pulse_transition_guard ON app.alliance_pulse;
|
||||
CREATE TRIGGER trg_alliance_pulse_transition_guard
|
||||
BEFORE UPDATE ON app.alliance_pulse
|
||||
FOR EACH ROW EXECUTE FUNCTION audit.enforce_alliance_pulse_transition();
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_alliance_pulse_delete_guard ON app.alliance_pulse;
|
||||
CREATE TRIGGER trg_alliance_pulse_delete_guard
|
||||
BEFORE DELETE ON app.alliance_pulse
|
||||
FOR EACH ROW EXECUTE FUNCTION audit.reject_measurement_mutation();
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_alliance_pulse_status_audit ON app.alliance_pulse;
|
||||
CREATE TRIGGER trg_alliance_pulse_status_audit
|
||||
AFTER INSERT OR UPDATE ON app.alliance_pulse
|
||||
FOR EACH ROW EXECUTE FUNCTION audit.log_alliance_pulse_status();
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_alliance_pulse_status_event_append_only
|
||||
ON audit.alliance_pulse_status_event;
|
||||
CREATE TRIGGER trg_alliance_pulse_status_event_append_only
|
||||
BEFORE UPDATE OR DELETE ON audit.alliance_pulse_status_event
|
||||
FOR EACH ROW EXECUTE FUNCTION audit.reject_measurement_mutation();
|
||||
|
||||
-- 기존 pulse가 있는 개발 DB에 재적용해도 최초 상태 스냅샷은 한 번만 보강한다.
|
||||
INSERT INTO audit.alliance_pulse_status_event (
|
||||
pulse_id, from_status, to_status, error_code, revealed_at,
|
||||
changed_by_uid, changed_by_role, ai_view, changed_at
|
||||
)
|
||||
SELECT
|
||||
p.pulse_id, NULL, 'awaiting_agents', NULL, NULL,
|
||||
NULL, 'migration', NULL, p.created_at
|
||||
FROM app.alliance_pulse p
|
||||
ON CONFLICT (pulse_id, changed_at, to_status) DO NOTHING;
|
||||
|
||||
INSERT INTO audit.alliance_pulse_status_event (
|
||||
pulse_id, from_status, to_status, error_code, revealed_at,
|
||||
changed_by_uid, changed_by_role, ai_view, changed_at
|
||||
)
|
||||
SELECT
|
||||
p.pulse_id, 'awaiting_agents', p.status, p.error_code, p.revealed_at,
|
||||
NULL, 'migration', NULL, p.updated_at
|
||||
FROM app.alliance_pulse p
|
||||
WHERE p.status <> 'awaiting_agents'
|
||||
ON CONFLICT (pulse_id, changed_at, to_status) DO NOTHING;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ds.benchmark_case (
|
||||
case_id TEXT NOT NULL,
|
||||
version TEXT NOT NULL,
|
||||
scene_type TEXT NOT NULL CHECK (
|
||||
scene_type IN (
|
||||
'goal_mismatch','task_mismatch','empathic_miss','withdrawal','confrontation',
|
||||
'successful_repair','failed_repair','warm_but_directionless'
|
||||
)
|
||||
),
|
||||
title_ko TEXT NOT NULL,
|
||||
description_ko TEXT NOT NULL,
|
||||
turns JSONB NOT NULL CHECK (jsonb_typeof(turns) = 'array'),
|
||||
expected JSONB NOT NULL CHECK (jsonb_typeof(expected) = 'array'),
|
||||
forbidden_claims JSONB NOT NULL CHECK (jsonb_typeof(forbidden_claims) = 'array'),
|
||||
tags TEXT[] NOT NULL DEFAULT '{}',
|
||||
source_kind TEXT NOT NULL DEFAULT 'observed_runtime'
|
||||
CHECK (source_kind = 'observed_runtime'),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (case_id, version)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ds.benchmark_observation (
|
||||
observation_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
case_id TEXT NOT NULL,
|
||||
case_version TEXT NOT NULL,
|
||||
model_run_id UUID NOT NULL REFERENCES audit.model_run(model_run_id) ON DELETE RESTRICT,
|
||||
result JSONB NOT NULL,
|
||||
passed BOOLEAN NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
FOREIGN KEY (case_id, case_version)
|
||||
REFERENCES ds.benchmark_case(case_id, version) ON DELETE RESTRICT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_benchmark_observation_case_created
|
||||
ON ds.benchmark_observation(case_id, case_version, created_at DESC);
|
||||
|
||||
ALTER TABLE app.measurement_instrument ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE app.measurement_event ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE app.alliance_pulse ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE app.self_assessment ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE audit.alliance_pulse_status_event ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE audit.model_run ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE ds.benchmark_case ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE ds.benchmark_observation ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
DROP POLICY IF EXISTS p_measurement_instrument_select ON app.measurement_instrument;
|
||||
DROP POLICY IF EXISTS p_measurement_instrument_insert ON app.measurement_instrument;
|
||||
CREATE POLICY p_measurement_instrument_select ON app.measurement_instrument
|
||||
FOR SELECT USING (true);
|
||||
CREATE POLICY p_measurement_instrument_insert ON app.measurement_instrument
|
||||
FOR INSERT WITH CHECK (
|
||||
app.is_ai_context() OR app.current_role_name() IN ('admin','instructor')
|
||||
);
|
||||
|
||||
DROP POLICY IF EXISTS p_measurement_event_select ON app.measurement_event;
|
||||
DROP POLICY IF EXISTS p_measurement_event_insert ON app.measurement_event;
|
||||
CREATE POLICY p_measurement_event_select ON app.measurement_event
|
||||
FOR SELECT USING (
|
||||
(
|
||||
app.is_ai_context()
|
||||
AND current_setting('app.current_ai_view', true) = ANY(visible_to)
|
||||
)
|
||||
OR (
|
||||
NOT app.is_ai_context()
|
||||
AND EXISTS (SELECT 1 FROM app.sessions s WHERE s.id = app.measurement_event.session_id)
|
||||
AND (
|
||||
app.current_role_name() IN ('admin','instructor')
|
||||
OR 'counselor' = ANY(visible_to)
|
||||
)
|
||||
)
|
||||
);
|
||||
CREATE POLICY p_measurement_event_insert ON app.measurement_event
|
||||
FOR INSERT WITH CHECK (
|
||||
app.is_ai_context()
|
||||
OR app.current_role_name() IN ('admin','instructor')
|
||||
OR (
|
||||
source_kind = 'learner_reported'
|
||||
AND perspective = 'learner_self_report'
|
||||
AND 'counselor' = ANY(visible_to)
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM app.sessions s
|
||||
WHERE s.id = app.measurement_event.session_id
|
||||
AND s.learner_id = app.current_uid()
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
DROP POLICY IF EXISTS p_alliance_pulse_select ON app.alliance_pulse;
|
||||
DROP POLICY IF EXISTS p_alliance_pulse_insert ON app.alliance_pulse;
|
||||
DROP POLICY IF EXISTS p_alliance_pulse_update ON app.alliance_pulse;
|
||||
CREATE POLICY p_alliance_pulse_select ON app.alliance_pulse
|
||||
FOR SELECT USING (
|
||||
app.is_ai_context()
|
||||
OR EXISTS (SELECT 1 FROM app.sessions s WHERE s.id = app.alliance_pulse.session_id)
|
||||
);
|
||||
CREATE POLICY p_alliance_pulse_insert ON app.alliance_pulse
|
||||
FOR INSERT WITH CHECK (
|
||||
app.is_ai_context()
|
||||
OR app.current_role_name() IN ('admin','instructor')
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM app.sessions s
|
||||
WHERE s.id = app.alliance_pulse.session_id
|
||||
AND s.learner_id = app.current_uid()
|
||||
)
|
||||
);
|
||||
CREATE POLICY p_alliance_pulse_update ON app.alliance_pulse
|
||||
FOR UPDATE USING (
|
||||
app.is_ai_context() OR app.current_role_name() IN ('admin','instructor')
|
||||
) WITH CHECK (
|
||||
app.is_ai_context() OR app.current_role_name() IN ('admin','instructor')
|
||||
);
|
||||
|
||||
DROP POLICY IF EXISTS p_self_assessment_select ON app.self_assessment;
|
||||
DROP POLICY IF EXISTS p_self_assessment_insert ON app.self_assessment;
|
||||
CREATE POLICY p_self_assessment_select ON app.self_assessment
|
||||
FOR SELECT USING (
|
||||
(
|
||||
app.is_ai_context()
|
||||
AND current_setting('app.current_ai_view', true) IN ('evaluator','supervisor','research')
|
||||
)
|
||||
OR (
|
||||
NOT app.is_ai_context()
|
||||
AND EXISTS (SELECT 1 FROM app.sessions s WHERE s.id = app.self_assessment.session_id)
|
||||
)
|
||||
);
|
||||
CREATE POLICY p_self_assessment_insert ON app.self_assessment
|
||||
FOR INSERT WITH CHECK (
|
||||
app.current_role_name() IN ('admin','instructor')
|
||||
OR (
|
||||
NOT app.is_ai_context()
|
||||
AND learner_id = app.current_uid()
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM app.sessions s
|
||||
WHERE s.id = app.self_assessment.session_id
|
||||
AND s.learner_id = app.current_uid()
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
DROP POLICY IF EXISTS p_alliance_pulse_status_event_select
|
||||
ON audit.alliance_pulse_status_event;
|
||||
CREATE POLICY p_alliance_pulse_status_event_select
|
||||
ON audit.alliance_pulse_status_event
|
||||
FOR SELECT USING (
|
||||
(
|
||||
app.is_ai_context()
|
||||
AND current_setting('app.current_ai_view', true) IN ('evaluator','supervisor','research')
|
||||
)
|
||||
OR (
|
||||
NOT app.is_ai_context()
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM app.alliance_pulse p
|
||||
WHERE p.pulse_id = audit.alliance_pulse_status_event.pulse_id
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
DROP POLICY IF EXISTS p_model_run_select ON audit.model_run;
|
||||
DROP POLICY IF EXISTS p_model_run_insert ON audit.model_run;
|
||||
CREATE POLICY p_model_run_select ON audit.model_run
|
||||
FOR SELECT USING (
|
||||
app.is_ai_context() OR app.current_role_name() IN ('admin','instructor')
|
||||
);
|
||||
CREATE POLICY p_model_run_insert ON audit.model_run
|
||||
FOR INSERT WITH CHECK (
|
||||
app.is_ai_context() OR app.current_role_name() IN ('admin','instructor')
|
||||
);
|
||||
|
||||
DROP POLICY IF EXISTS p_benchmark_case_select ON ds.benchmark_case;
|
||||
DROP POLICY IF EXISTS p_benchmark_case_insert ON ds.benchmark_case;
|
||||
CREATE POLICY p_benchmark_case_select ON ds.benchmark_case
|
||||
FOR SELECT USING (
|
||||
app.is_ai_context() OR app.current_role_name() IN ('admin','instructor')
|
||||
);
|
||||
CREATE POLICY p_benchmark_case_insert ON ds.benchmark_case
|
||||
FOR INSERT WITH CHECK (
|
||||
app.is_ai_context() OR app.current_role_name() IN ('admin','instructor')
|
||||
);
|
||||
|
||||
DROP POLICY IF EXISTS p_benchmark_observation_select ON ds.benchmark_observation;
|
||||
DROP POLICY IF EXISTS p_benchmark_observation_insert ON ds.benchmark_observation;
|
||||
CREATE POLICY p_benchmark_observation_select ON ds.benchmark_observation
|
||||
FOR SELECT USING (
|
||||
app.is_ai_context() OR app.current_role_name() IN ('admin','instructor')
|
||||
);
|
||||
CREATE POLICY p_benchmark_observation_insert ON ds.benchmark_observation
|
||||
FOR INSERT WITH CHECK (
|
||||
app.is_ai_context() OR app.current_role_name() IN ('admin','instructor')
|
||||
);
|
||||
Loading…
Add table
Add a link
Reference in a new issue