회기 연속성과 멀티 케이스 계약을 영속화
This commit is contained in:
parent
be08c0b573
commit
72353ecd82
26 changed files with 2170 additions and 127 deletions
|
|
@ -347,7 +347,8 @@ CREATE INDEX IF NOT EXISTS idx_sess_sum_emb ON app.session_summary
|
|||
|
||||
-- =============================================================================
|
||||
-- 8. 메모리 ④ SEMANTIC (설계서 §3.5) — case_profile(evolving) + pinned_fact
|
||||
-- 케이스 = (persona_id 템플릿) × (learner_id 인스턴스). 학습자별 독립 연속체.
|
||||
-- 케이스 = (persona_id 템플릿) × (learner_id 인스턴스) × 시작 시점.
|
||||
-- 같은 내담자도 새 사례와 이어지는 사례를 분리해 학습자별 독립 연속체를 보존한다.
|
||||
-- =============================================================================
|
||||
CREATE TABLE IF NOT EXISTS app.case_profile (
|
||||
case_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
|
|
@ -360,9 +361,10 @@ CREATE TABLE IF NOT EXISTS app.case_profile (
|
|||
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)
|
||||
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);
|
||||
|
|
|
|||
32
infra/db/init/20_public_bootstrap_ticket_events.sql
Normal file
32
infra/db/init/20_public_bootstrap_ticket_events.sql
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
-- 공개 부트스트랩 장애의 재시도 이력을 support_ticket 본문과 분리해 append-only로 보존한다.
|
||||
-- 브라우저가 보낼 수 있는 값은 API의 폐쇄 enum으로 더 제한하므로 이 테이블에는
|
||||
-- 응답 원문, URL, 쿠키, 계정 식별자 같은 고객 데이터가 들어가지 않는다.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS app.support_ticket_event (
|
||||
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
ticket_id UUID NOT NULL
|
||||
REFERENCES app.support_ticket(id) ON DELETE CASCADE,
|
||||
observer_kind TEXT NOT NULL CHECK (observer_kind = 'public_browser'),
|
||||
event_kind TEXT NOT NULL CHECK (
|
||||
event_kind IN ('auth_restore_failure', 'boot_render_failure')
|
||||
),
|
||||
status_code SMALLINT NOT NULL CHECK (status_code IN (500, 502, 503, 504)),
|
||||
attempt TEXT NOT NULL CHECK (attempt IN ('automatic', 'retry')),
|
||||
source_path TEXT NOT NULL CHECK (source_path = '/ops/public-bootstrap'),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_support_ticket_event_ticket_latest
|
||||
ON app.support_ticket_event(ticket_id, created_at DESC);
|
||||
|
||||
ALTER TABLE app.support_ticket_event ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
DROP POLICY IF EXISTS p_support_ticket_event_select ON app.support_ticket_event;
|
||||
CREATE POLICY p_support_ticket_event_select
|
||||
ON app.support_ticket_event FOR SELECT
|
||||
USING (app.current_role_name() = 'admin');
|
||||
|
||||
DROP POLICY IF EXISTS p_support_ticket_event_insert ON app.support_ticket_event;
|
||||
CREATE POLICY p_support_ticket_event_insert
|
||||
ON app.support_ticket_event FOR INSERT
|
||||
WITH CHECK (app.current_role_name() = 'admin');
|
||||
87
infra/db/init/21_single_active_session.sql
Normal file
87
infra/db/init/21_single_active_session.sql
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
-- =============================================================================
|
||||
-- Vignette · migration 21 — learner-persona별 단일 활성 회기
|
||||
-- =============================================================================
|
||||
-- 기존 DB 적용 전 owner가 아래 읽기 전용 점검을 실행한다.
|
||||
-- 중복 또는 실패 후 남은 invalid index가 있으면 임의 종료·DROP 하지 말고
|
||||
-- 소유자가 보존·복구 방식을 결정해야 한다.
|
||||
--
|
||||
-- SELECT learner_id, persona_id, count(*)
|
||||
-- FROM app.sessions
|
||||
-- WHERE ended_at IS NULL AND persona_id IS NOT NULL
|
||||
-- GROUP BY learner_id, persona_id
|
||||
-- HAVING count(*) > 1;
|
||||
--
|
||||
-- CREATE INDEX CONCURRENTLY는 transaction block 안에서 실행할 수 없다.
|
||||
-- release agent가 online migration으로 owner psql에 전달한다.
|
||||
-- 이 파일의 전·후 condition은 IF NOT EXISTS가 invalid/wrong index를 조용히
|
||||
-- 건너뛰는 경우를 fail-closed로 막는다.
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
target_index oid := to_regclass('app.uq_sessions_one_active_learner_persona');
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM app.sessions
|
||||
WHERE ended_at IS NULL
|
||||
AND persona_id IS NOT NULL
|
||||
GROUP BY learner_id, persona_id
|
||||
HAVING count(*) > 1
|
||||
) THEN
|
||||
RAISE EXCEPTION
|
||||
'migration 21 blocked: duplicate active learner-persona sessions exist';
|
||||
END IF;
|
||||
|
||||
IF target_index IS NOT NULL AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM pg_index AS index_meta
|
||||
WHERE index_meta.indexrelid = target_index
|
||||
AND index_meta.indrelid = 'app.sessions'::regclass
|
||||
AND index_meta.indisvalid
|
||||
AND index_meta.indisready
|
||||
AND index_meta.indisunique
|
||||
AND index_meta.indnkeyatts = 2
|
||||
AND pg_get_indexdef(index_meta.indexrelid, 1, true) = 'learner_id'
|
||||
AND pg_get_indexdef(index_meta.indexrelid, 2, true) = 'persona_id'
|
||||
AND regexp_replace(
|
||||
pg_get_expr(index_meta.indpred, index_meta.indrelid),
|
||||
'[[:space:]()]',
|
||||
'',
|
||||
'g'
|
||||
) = 'ended_atISNULLANDpersona_idISNOTNULL'
|
||||
) THEN
|
||||
RAISE EXCEPTION
|
||||
'migration 21 blocked: target index exists but is invalid or has a different definition';
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
|
||||
CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS uq_sessions_one_active_learner_persona
|
||||
ON app.sessions (learner_id, persona_id)
|
||||
WHERE ended_at IS NULL AND persona_id IS NOT NULL;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM pg_index AS index_meta
|
||||
WHERE index_meta.indexrelid = 'app.uq_sessions_one_active_learner_persona'::regclass
|
||||
AND index_meta.indrelid = 'app.sessions'::regclass
|
||||
AND index_meta.indisvalid
|
||||
AND index_meta.indisready
|
||||
AND index_meta.indisunique
|
||||
AND index_meta.indnkeyatts = 2
|
||||
AND pg_get_indexdef(index_meta.indexrelid, 1, true) = 'learner_id'
|
||||
AND pg_get_indexdef(index_meta.indexrelid, 2, true) = 'persona_id'
|
||||
AND regexp_replace(
|
||||
pg_get_expr(index_meta.indpred, index_meta.indrelid),
|
||||
'[[:space:]()]',
|
||||
'',
|
||||
'g'
|
||||
) = 'ended_atISNULLANDpersona_idISNOTNULL'
|
||||
) THEN
|
||||
RAISE EXCEPTION
|
||||
'migration 21 failed: valid target unique index was not created';
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
129
infra/db/init/22_case_profile_multi_case.sql
Normal file
129
infra/db/init/22_case_profile_multi_case.sql
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
-- =============================================================================
|
||||
-- Vignette · migration 22 — 동일 learner-persona의 새 사례 분리
|
||||
-- =============================================================================
|
||||
-- migration 21의 learner-persona 단일 활성 회기 제약은 그대로 유지한다.
|
||||
-- 이 migration은 끝난 기존 사례를 보존한 채, 같은 내담자에 새 사례(case_profile)를
|
||||
-- 만들 수 있도록 legacy pair unique만 제거한다. raw transcript·memory row는 수정하지 않는다.
|
||||
--
|
||||
-- CREATE INDEX CONCURRENTLY는 transaction block 안에서 실행할 수 없다.
|
||||
-- release agent가 online migration으로 owner psql에 전달한다.
|
||||
|
||||
SET lock_timeout = '5s';
|
||||
SET statement_timeout = '15min';
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
legacy_constraints text[];
|
||||
target_index oid := to_regclass('app.idx_case_profile_learner_persona_activity');
|
||||
BEGIN
|
||||
SELECT array_agg(candidate.conname ORDER BY candidate.conname)
|
||||
INTO legacy_constraints
|
||||
FROM (
|
||||
SELECT constraint_meta.conname
|
||||
FROM pg_constraint AS constraint_meta
|
||||
WHERE constraint_meta.conrelid = 'app.case_profile'::regclass
|
||||
AND constraint_meta.contype = 'u'
|
||||
AND ARRAY(
|
||||
SELECT attribute_meta.attname
|
||||
FROM unnest(constraint_meta.conkey) WITH ORDINALITY AS key_column(attnum, ordinality)
|
||||
JOIN pg_attribute AS attribute_meta
|
||||
ON attribute_meta.attrelid = constraint_meta.conrelid
|
||||
AND attribute_meta.attnum = key_column.attnum
|
||||
ORDER BY key_column.ordinality
|
||||
) = ARRAY['persona_id', 'learner_id']::text[]
|
||||
) AS candidate;
|
||||
|
||||
IF COALESCE(array_length(legacy_constraints, 1), 0) > 1 THEN
|
||||
RAISE EXCEPTION
|
||||
'migration 22 blocked: multiple legacy case_profile persona-learner unique constraints exist';
|
||||
END IF;
|
||||
|
||||
IF target_index IS NOT NULL AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM pg_index AS index_meta
|
||||
WHERE index_meta.indexrelid = target_index
|
||||
AND index_meta.indrelid = 'app.case_profile'::regclass
|
||||
AND index_meta.indisvalid
|
||||
AND index_meta.indisready
|
||||
AND NOT index_meta.indisunique
|
||||
AND index_meta.indnkeyatts = 4
|
||||
AND pg_get_indexdef(index_meta.indexrelid) LIKE
|
||||
'%(learner_id, persona_id, updated_at DESC, case_id)%'
|
||||
) THEN
|
||||
RAISE EXCEPTION
|
||||
'migration 22 blocked: target index exists but is invalid or has a different definition';
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_case_profile_learner_persona_activity
|
||||
ON app.case_profile (learner_id, persona_id, updated_at DESC, case_id);
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
legacy_constraints text[];
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM pg_index AS index_meta
|
||||
WHERE index_meta.indexrelid = 'app.idx_case_profile_learner_persona_activity'::regclass
|
||||
AND index_meta.indrelid = 'app.case_profile'::regclass
|
||||
AND index_meta.indisvalid
|
||||
AND index_meta.indisready
|
||||
AND NOT index_meta.indisunique
|
||||
AND index_meta.indnkeyatts = 4
|
||||
AND pg_get_indexdef(index_meta.indexrelid) LIKE
|
||||
'%(learner_id, persona_id, updated_at DESC, case_id)%'
|
||||
) THEN
|
||||
RAISE EXCEPTION
|
||||
'migration 22 failed: valid case activity index was not created';
|
||||
END IF;
|
||||
|
||||
SELECT array_agg(candidate.conname ORDER BY candidate.conname)
|
||||
INTO legacy_constraints
|
||||
FROM (
|
||||
SELECT constraint_meta.conname
|
||||
FROM pg_constraint AS constraint_meta
|
||||
WHERE constraint_meta.conrelid = 'app.case_profile'::regclass
|
||||
AND constraint_meta.contype = 'u'
|
||||
AND ARRAY(
|
||||
SELECT attribute_meta.attname
|
||||
FROM unnest(constraint_meta.conkey) WITH ORDINALITY AS key_column(attnum, ordinality)
|
||||
JOIN pg_attribute AS attribute_meta
|
||||
ON attribute_meta.attrelid = constraint_meta.conrelid
|
||||
AND attribute_meta.attnum = key_column.attnum
|
||||
ORDER BY key_column.ordinality
|
||||
) = ARRAY['persona_id', 'learner_id']::text[]
|
||||
) AS candidate;
|
||||
|
||||
IF COALESCE(array_length(legacy_constraints, 1), 0) > 1 THEN
|
||||
RAISE EXCEPTION
|
||||
'migration 22 blocked: multiple legacy case_profile persona-learner unique constraints exist';
|
||||
END IF;
|
||||
|
||||
IF COALESCE(array_length(legacy_constraints, 1), 0) = 1 THEN
|
||||
EXECUTE format(
|
||||
'ALTER TABLE app.case_profile DROP CONSTRAINT %I',
|
||||
legacy_constraints[1]
|
||||
);
|
||||
END IF;
|
||||
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM pg_constraint AS constraint_meta
|
||||
WHERE constraint_meta.conrelid = 'app.case_profile'::regclass
|
||||
AND constraint_meta.contype = 'u'
|
||||
AND ARRAY(
|
||||
SELECT attribute_meta.attname
|
||||
FROM unnest(constraint_meta.conkey) WITH ORDINALITY AS key_column(attnum, ordinality)
|
||||
JOIN pg_attribute AS attribute_meta
|
||||
ON attribute_meta.attrelid = constraint_meta.conrelid
|
||||
AND attribute_meta.attnum = key_column.attnum
|
||||
ORDER BY key_column.ordinality
|
||||
) = ARRAY['persona_id', 'learner_id']::text[]
|
||||
) THEN
|
||||
RAISE EXCEPTION
|
||||
'migration 22 failed: legacy case_profile persona-learner unique constraint remains';
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
Loading…
Add table
Add a link
Reference in a new issue