meridian-api / scripts /supabase_schema.sql
Demon1212122's picture
fix :major fix
86d774d
Raw
History Blame Contribute Delete
12.8 kB
-- ============================================================
-- Meridian β€” Supabase Database Schema (unified, idempotent)
-- Run this entire file in: Supabase Dashboard β†’ SQL Editor β†’ New Query
--
-- Safe to re-run: every CREATE uses IF NOT EXISTS, every ALTER uses
-- IF NOT EXISTS, the function is DROPped before CREATE so return-type
-- changes don't error.
-- ============================================================
-- ── Enable pgvector ──────────────────────────────────────────────
CREATE EXTENSION IF NOT EXISTS vector;
-- ── Profiles ──────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS public.profiles (
id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
display_name TEXT,
age INT CHECK (age BETWEEN 13 AND 120),
preferred_modality TEXT DEFAULT 'cbt'
CHECK (preferred_modality IN ('cbt','act','dbt','mindfulness','sfbt')),
consent_given BOOLEAN NOT NULL DEFAULT false,
consent_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now()
);
-- ── Sessions ──────────────────────────────────────────────────────
-- Replaces the in-memory session_store dict in main.py.
-- prob_history is Phase 10's per-turn SVM depression probability
-- trajectory β€” see backend/agents/diagnosis.py::_decide().
CREATE TABLE IF NOT EXISTS public.sessions (
id TEXT PRIMARY KEY,
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
mode TEXT NOT NULL CHECK (mode IN ('diagnosis','therapy')),
modality TEXT CHECK (modality IN ('cbt','act','dbt','mindfulness','sfbt')),
embedding_buffer JSONB NOT NULL DEFAULT '[]',
char_buffer_total INT NOT NULL DEFAULT 0,
prob_history JSONB NOT NULL DEFAULT '[]',
diagnosis_ready BOOLEAN NOT NULL DEFAULT false,
crisis_detected BOOLEAN NOT NULL DEFAULT false,
risk_scores JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now()
);
-- Phase 10 backfill β€” ensures existing DBs without prob_history
-- get the column added. No-op on fresh DBs (column already exists
-- from the CREATE TABLE above).
ALTER TABLE public.sessions
ADD COLUMN IF NOT EXISTS prob_history JSONB NOT NULL DEFAULT '[]';
CREATE INDEX IF NOT EXISTS sessions_user_id_idx ON public.sessions(user_id);
-- ── Messages ──────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS public.messages (
id BIGSERIAL PRIMARY KEY,
session_id TEXT NOT NULL REFERENCES public.sessions(id) ON DELETE CASCADE,
role TEXT NOT NULL CHECK (role IN ('user','assistant')),
content TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT now()
);
CREATE INDEX IF NOT EXISTS messages_session_id_idx ON public.messages(session_id);
-- ── Audit Log ─────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS public.audit_log (
id BIGSERIAL PRIMARY KEY,
user_id UUID REFERENCES auth.users(id) ON DELETE SET NULL,
session_id TEXT,
event_type TEXT NOT NULL,
-- event_type values:
-- 'consent_accepted' β€” user accepted ToS on onboarding page
-- 'crisis_detected' β€” crisis node fired during a chat turn
-- 'diagnosis_result' β€” MentalRoBERTa SVM classification completed
-- 'guardian_block' β€” guardian agent blocked a request
-- 'rate_limit_hit' β€” user exceeded 20 req/min
-- 'phq9_assessment' β€” completed 9-question PHQ-9 cycle
metadata JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ DEFAULT now()
);
CREATE INDEX IF NOT EXISTS audit_log_user_id_idx ON public.audit_log(user_id);
CREATE INDEX IF NOT EXISTS audit_log_event_type_idx ON public.audit_log(event_type);
CREATE INDEX IF NOT EXISTS audit_log_created_at_idx ON public.audit_log(created_at DESC);
-- ── Memory Embeddings (pgvector β€” replaces in-memory ChromaDB) ────
-- Uses sentence-transformers/all-MiniLM-L6-v2 β†’ 384 dimensions.
CREATE TABLE IF NOT EXISTS public.memory_embeddings (
id BIGSERIAL PRIMARY KEY,
session_id TEXT REFERENCES public.sessions(id) ON DELETE CASCADE,
user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE,
content TEXT NOT NULL,
embedding vector(384),
created_at TIMESTAMPTZ DEFAULT now()
);
-- IVFFlat index for approximate nearest-neighbour search.
-- Run AFTER inserting at least 100 rows for best performance.
CREATE INDEX IF NOT EXISTS memory_embeddings_vector_idx
ON public.memory_embeddings
USING ivfflat (embedding vector_cosine_ops)
WITH (lists = 50);
-- ── PHQ-9 Trajectory ──────────────────────────────────────────────
-- phq9_responses β€” every individual answered question
-- phq9_assessments β€” sealed 9-question totals + severity (chart source)
CREATE TABLE IF NOT EXISTS public.phq9_responses (
id BIGSERIAL PRIMARY KEY,
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
session_id TEXT NOT NULL REFERENCES public.sessions(id) ON DELETE CASCADE,
question_index INT NOT NULL CHECK (question_index BETWEEN 0 AND 8),
score INT NOT NULL CHECK (score BETWEEN 0 AND 3),
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS phq9_responses_session_idx
ON public.phq9_responses(session_id);
CREATE INDEX IF NOT EXISTS phq9_responses_user_idx
ON public.phq9_responses(user_id, created_at DESC);
CREATE TABLE IF NOT EXISTS public.phq9_assessments (
id BIGSERIAL PRIMARY KEY,
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
session_id TEXT NOT NULL REFERENCES public.sessions(id) ON DELETE CASCADE,
total_score INT NOT NULL CHECK (total_score BETWEEN 0 AND 27),
severity TEXT NOT NULL CHECK (severity IN
('minimal','mild','moderate','moderately severe','severe')),
-- 9 scores in question-index order [s0, s1, ..., s8]
responses JSONB NOT NULL DEFAULT '[]',
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS phq9_assessments_user_idx
ON public.phq9_assessments(user_id, created_at DESC);
-- ── Row Level Security ────────────────────────────────────────────
-- Users can ONLY read/write their own rows. Enforced at DB level.
ALTER TABLE public.profiles ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.sessions ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.messages ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.memory_embeddings ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.phq9_responses ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.phq9_assessments ENABLE ROW LEVEL SECURITY;
-- audit_log is write-only for users, readable only via service key (server-side)
ALTER TABLE public.audit_log ENABLE ROW LEVEL SECURITY;
-- Profiles: own row only
DROP POLICY IF EXISTS "profiles: own row" ON public.profiles;
CREATE POLICY "profiles: own row" ON public.profiles
FOR ALL USING (auth.uid() = id);
-- Sessions: own rows only
DROP POLICY IF EXISTS "sessions: own rows" ON public.sessions;
CREATE POLICY "sessions: own rows" ON public.sessions
FOR ALL USING (auth.uid() = user_id);
-- Messages: own sessions' messages only
DROP POLICY IF EXISTS "messages: own sessions" ON public.messages;
CREATE POLICY "messages: own sessions" ON public.messages
FOR ALL USING (
session_id IN (
SELECT id FROM public.sessions WHERE user_id = auth.uid()
)
);
-- Memory: own rows only
DROP POLICY IF EXISTS "memory: own rows" ON public.memory_embeddings;
CREATE POLICY "memory: own rows" ON public.memory_embeddings
FOR ALL USING (auth.uid() = user_id);
-- PHQ-9: own rows only
DROP POLICY IF EXISTS "phq9_responses: own rows" ON public.phq9_responses;
CREATE POLICY "phq9_responses: own rows" ON public.phq9_responses
FOR ALL USING (auth.uid() = user_id);
DROP POLICY IF EXISTS "phq9_assessments: own rows" ON public.phq9_assessments;
CREATE POLICY "phq9_assessments: own rows" ON public.phq9_assessments
FOR ALL USING (auth.uid() = user_id);
-- Audit log: users cannot read; service key (backend) can write via service role
DROP POLICY IF EXISTS "audit_log: service write only" ON public.audit_log;
CREATE POLICY "audit_log: service write only" ON public.audit_log
FOR INSERT WITH CHECK (true); -- backend uses service key which bypasses RLS
-- ── Auto-update updated_at ────────────────────────────────────────
CREATE OR REPLACE FUNCTION public.set_updated_at()
RETURNS TRIGGER LANGUAGE plpgsql AS $$
BEGIN
NEW.updated_at = now();
RETURN NEW;
END;
$$;
DROP TRIGGER IF EXISTS set_profiles_updated_at ON public.profiles;
CREATE TRIGGER set_profiles_updated_at
BEFORE UPDATE ON public.profiles
FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
DROP TRIGGER IF EXISTS set_sessions_updated_at ON public.sessions;
CREATE TRIGGER set_sessions_updated_at
BEFORE UPDATE ON public.sessions
FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
-- ============================================================
-- Memory retrieval RPC
-- Called from backend/agents/memory.py::retrieve_similar.
-- Returns the top-N most semantically similar past exchanges
-- for a given user, across ALL of their sessions.
--
-- IMPORTANT: we DROP first because Postgres won't let
-- CREATE OR REPLACE change a function's return type. If you've
-- ever run a version of this with a different return signature
-- (e.g. session_id uuid instead of text), the CREATE OR REPLACE
-- alone will fail with error 42P13. The explicit DROP makes
-- the rerun safe regardless of what's already in the DB.
-- ============================================================
DROP FUNCTION IF EXISTS public.match_memory_embeddings(vector, uuid, int);
CREATE OR REPLACE FUNCTION public.match_memory_embeddings(
query_embedding vector(384),
target_user_id uuid,
match_count int DEFAULT 2
)
RETURNS TABLE (
id bigint,
session_id text,
content text,
similarity float
)
LANGUAGE plpgsql
STABLE
AS $$
BEGIN
RETURN QUERY
SELECT
m.id,
m.session_id,
m.content,
-- pgvector's <=> is cosine distance (0 = identical, 2 = opposite).
-- We return similarity = 1 - distance so callers can sort/threshold
-- on the more intuitive "higher is better" scale if they want.
1 - (m.embedding <=> query_embedding) AS similarity
FROM public.memory_embeddings m
WHERE m.user_id = target_user_id
AND m.embedding IS NOT NULL
ORDER BY m.embedding <=> query_embedding
LIMIT match_count;
END;
$$;
GRANT EXECUTE ON FUNCTION public.match_memory_embeddings(vector, uuid, int)
TO authenticated, service_role;
-- ── Useful admin queries (run manually in SQL editor) ─────────────
-- Crisis events last 7 days:
-- SELECT u.email, a.session_id, a.metadata, a.created_at
-- FROM public.audit_log a JOIN auth.users u ON a.user_id = u.id
-- WHERE a.event_type = 'crisis_detected'
-- AND a.created_at > now() - interval '7 days'
-- ORDER BY a.created_at DESC;
-- Daily active users (last 30 days):
-- SELECT date_trunc('day', created_at) AS day, COUNT(DISTINCT user_id)
-- FROM public.sessions GROUP BY 1 ORDER BY 1 DESC LIMIT 30;
-- PHQ-9 severity distribution last 30 days:
-- SELECT severity, COUNT(*)
-- FROM public.phq9_assessments
-- WHERE created_at > now() - interval '30 days'
-- GROUP BY 1 ORDER BY 1;
-- Per-user PHQ-9 trajectory (deltas):
-- SELECT user_id, created_at, total_score,
-- total_score - LAG(total_score) OVER (
-- PARTITION BY user_id ORDER BY created_at
-- ) AS delta
-- FROM public.phq9_assessments
-- ORDER BY user_id, created_at;
-- Adaptive diagnosis β€” average turns to fire (Phase 10):
-- SELECT AVG(jsonb_array_length(prob_history)) AS avg_turns_to_fire
-- FROM public.sessions
-- WHERE mode = 'diagnosis' AND diagnosis_ready = true;