Search, chat and research over parliamentary speeches and documents
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

591 lines
28 KiB

-- Riksdagen database schema for PostgreSQL + pgvector
-- Run once: psql -U riksdagen -d riksdagen -f schema.sql
CREATE EXTENSION IF NOT EXISTS vector;
CREATE EXTENSION IF NOT EXISTS pg_trgm; -- for ILIKE index support on prefix searches
-- ─────────────────────────────────────────
-- people
-- ─────────────────────────────────────────
CREATE TABLE IF NOT EXISTS people (
person_id TEXT PRIMARY KEY,
source_record_id TEXT,
source_record_guid TEXT,
source_id TEXT,
birth_year TEXT,
gender TEXT,
last_name TEXT,
first_name TEXT,
sort_name TEXT,
home_town TEXT,
party TEXT,
constituency TEXT,
status TEXT,
source_url TEXT,
image_url_small TEXT,
image_url_medium TEXT,
image_url_large TEXT,
assignments JSONB, -- nested assignment array, kept as-is
contact_details JSONB, -- nested contact info array, kept as-is
name TEXT,
active BOOLEAN
);
CREATE INDEX IF NOT EXISTS people_parti_idx ON people (party);
CREATE INDEX IF NOT EXISTS people_namn_idx ON people (name);
-- ─────────────────────────────────────────
-- speeches
-- ─────────────────────────────────────────
CREATE TABLE IF NOT EXISTS speeches (
-- Primary key: source_doc_id (e.g. "H40911"), called "id" in the source data
id TEXT PRIMARY KEY,
-- Original UUID from riksdagen (preserved for reference after key migration)
source_speech_id TEXT,
-- Core content
text TEXT,
section_title TEXT,
-- Metadata
sequence INTEGER,
activity_type TEXT,
speaker_name TEXT,
party TEXT,
person_id TEXT, -- references people(person_id), nullable
-- Date fields
date DATE, -- e.g. 2016-09-29
source_datetime TEXT, -- original datetime string from riksdagen API
year INTEGER,
session_year INTEGER, -- parliamentary session year (start year)
-- Document references
-- `source_doc_id` is the id of the protocol document this speech appeared in. It is
-- written by the ingest step and is NOT the same as `id` above, despite `id`
-- historically being described as "the source_doc_id" — that comment refers to the
-- source field named "id", which happens to look like a document id.
source_doc_id TEXT,
related_doc_id TEXT,
source_doc_number TEXT,
source_record_id TEXT,
title TEXT, -- debate_id/session title
-- Debate grouping
debate_id TEXT, -- e.g. "2016-09-29:56"
is_reply BOOLEAN,
-- LLM-generated fields
summary TEXT,
tags TEXT[],
-- URL fields (populated from riksdagen API, may be null)
url_video TEXT,
url_session TEXT,
url_audio TEXT,
url_audio_file TEXT,
audio_start_seconds INTEGER,
-- LLM-derived. `arguments` holds extracted argument sentences; the two
-- booleans are pipeline bookkeeping so a re-run can skip finished rows.
arguments TEXT[],
arguments_corrected BOOLEAN DEFAULT FALSE,
tagging_failed BOOLEAN DEFAULT FALSE,
-- Embedding of `summary`, for debate_id-level semantic search. Distinct from
-- `speech_chunks.embedding`, which covers the full text passage by passage.
summary_embedding vector(384),
-- Full-text search vector (auto-maintained by trigger)
search_vector TSVECTOR
);
-- Full-text search index (Swedish)
CREATE INDEX IF NOT EXISTS speeches_search_idx ON speeches USING GIN (search_vector);
-- Filtering indexes
CREATE INDEX IF NOT EXISTS speeches_debate_idx ON speeches (debate, sequence);
CREATE INDEX IF NOT EXISTS speeches_party_idx ON speeches (party);
CREATE INDEX IF NOT EXISTS speeches_date_idx ON speeches (date);
CREATE INDEX IF NOT EXISTS speeches_year_idx ON speeches (year);
CREATE INDEX IF NOT EXISTS speeches_person_idx ON speeches (person_id);
CREATE INDEX IF NOT EXISTS speeches_speaker_idx ON speeches USING GIN (to_tsvector('simple', coalesce(speaker_name, '')));
CREATE INDEX IF NOT EXISTS speeches_source_doc_idx ON speeches (dok_id);
-- Semantic search over per-speech summaries (separate from speech_chunks.embedding,
-- which indexes the full text passage by passage).
CREATE INDEX IF NOT EXISTS speeches_summary_embedding_idx ON speeches
USING hnsw (summary_embedding vector_cosine_ops) WITH (m = 16, ef_construction = 64);
-- Trigger to keep search_vector up to date on insert/update
CREATE OR REPLACE FUNCTION talks_search_vector_update() RETURNS TRIGGER AS $$
BEGIN
NEW.search_vector := to_tsvector('swedish', coalesce(NEW.text, ''));
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS talks_search_vector_trigger ON speeches;
CREATE TRIGGER talks_search_vector_trigger
BEFORE INSERT OR UPDATE OF text
ON speeches
FOR EACH ROW
EXECUTE FUNCTION talks_search_vector_update();
-- ─────────────────────────────────────────
-- speech_chunks (text speech_chunks + vector embeddings)
-- ─────────────────────────────────────────
CREATE TABLE IF NOT EXISTS speech_chunks (
-- Key: "{speech_id}:{chunk_index}", e.g. "H40911:0"
id TEXT PRIMARY KEY,
speech_id TEXT NOT NULL REFERENCES speeches(id) ON DELETE CASCADE,
chunk_index INTEGER NOT NULL,
text TEXT NOT NULL,
embedding vector(384)
);
CREATE INDEX IF NOT EXISTS speech_chunks_speech_idx ON speech_chunks (speech_id);
-- HNSW index for approximate nearest-neighbour cosine search
CREATE INDEX IF NOT EXISTS speech_chunks_embedding_idx ON speech_chunks USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 64);
-- ─────────────────────────────────────────
-- debates (aggregated debate summaries)
-- ─────────────────────────────────────────
CREATE TABLE IF NOT EXISTS debates (
-- Key: "{date}:{debate_index}", e.g. "2016-09-29:56"
id TEXT PRIMARY KEY,
date DATE,
summary TEXT,
num_talks INTEGER,
talk_summaries TEXT[], -- array of individual talk summary strings
talk_ids TEXT[], -- array of talk ids (without "speeches/" prefix)
summary_embedding vector(384)
);
CREATE INDEX IF NOT EXISTS debates_date_idx ON debates (date);
CREATE INDEX IF NOT EXISTS debates_summary_embedding_idx ON debates
USING hnsw (summary_embedding vector_cosine_ops) WITH (m = 16, ef_construction = 64);
-- ─────────────────────────────────────────
-- documents (motioner from riksdagens öppna data)
-- ─────────────────────────────────────────
CREATE TABLE IF NOT EXISTS documents (
-- Primary key: doc_id (e.g. "HD02846")
doc_id TEXT PRIMARY KEY,
-- Which kind of member-submitted document this is. Everything is 'motion'
-- today; the column exists so bills, written questions and committee reports
-- can share this table rather than each needing their own.
doc_type TEXT NOT NULL DEFAULT 'motion',
source_record_id TEXT,
-- Identity / classification
session_label TEXT, -- riksmöte, e.g. "2022/23"
designation TEXT, -- motion number within session_label, e.g. "846"
subtype TEXT, -- e.g. "Enskild motion", "Kommittémotion"
committee TEXT, -- committee the motion was referred to, e.g. "AU"
status TEXT, -- e.g. "Klar", "Inkommen"
-- Dates
date DATE,
source_updated_at TEXT, -- raw string, used for change detection
published_at TEXT,
session_year INTEGER, -- int(session_label[:4]), mirrors speeches.session_year
-- Content
title TEXT,
subtitle TEXT,
text TEXT, -- plain text extracted from the html field
proposals_text TEXT, -- concat of yrkande lydelser (high-signal, weighted B in FTS)
has_text BOOLEAN NOT NULL DEFAULT FALSE, -- false for scanned-PDF-only documents
-- Source URLs
url_text TEXT,
url_html TEXT,
url_pdf TEXT,
-- Authors (denormalized; relational detail in document_authors)
parties TEXT[] NOT NULL DEFAULT '{}',
author_names TEXT[] NOT NULL DEFAULT '{}', -- in signing order
-- Proposals and attachments, raw from dokumentstatus
proposals_raw JSONB, -- dokforslag list: yrkanden + utskottsförslag + kammarens beslut
attachments JSONB, -- dokbilaga list
num_proposals INTEGER NOT NULL DEFAULT 0,
-- LLM-generated fields (future parity with speeches)
summary TEXT,
-- Full-text search vector (auto-maintained by trigger)
search_vector TSVECTOR
);
CREATE INDEX IF NOT EXISTS documents_search_idx ON documents USING GIN (search_vector);
CREATE INDEX IF NOT EXISTS documents_date_idx ON documents (date);
CREATE INDEX IF NOT EXISTS documents_session_year_idx ON documents (year);
CREATE INDEX IF NOT EXISTS documents_committee_idx ON documents (committee);
CREATE INDEX IF NOT EXISTS documents_parties_idx ON documents USING GIN (parties);
CREATE OR REPLACE FUNCTION motions_search_vector_update() RETURNS TRIGGER AS $$
BEGIN
NEW.search_vector :=
setweight(to_tsvector('swedish', coalesce(NEW.title, '')), 'A') ||
setweight(to_tsvector('swedish', coalesce(NEW.proposals_text, '')), 'B') ||
setweight(to_tsvector('swedish', coalesce(NEW.subtitle, '')), 'C') ||
setweight(to_tsvector('swedish', coalesce(NEW.text, '')), 'D');
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS motions_search_vector_trigger ON documents;
CREATE TRIGGER motions_search_vector_trigger
BEFORE INSERT OR UPDATE OF title, subtitle, text, proposals_text
ON documents
FOR EACH ROW
EXECUTE FUNCTION motions_search_vector_update();
-- ─────────────────────────────────────────
-- document_authors (undertecknare, in signing order)
-- ─────────────────────────────────────────
CREATE TABLE IF NOT EXISTS document_authors (
doc_id TEXT NOT NULL REFERENCES documents(doc_id) ON DELETE CASCADE,
ordinal INTEGER NOT NULL, -- position in dokintressent list
-- Soft link to people(person_id); no FK — 1990s ids may be missing from people
person_id TEXT,
name TEXT,
party TEXT,
role TEXT, -- e.g. "undertecknare"
PRIMARY KEY (doc_id, ordinal)
);
CREATE INDEX IF NOT EXISTS document_authors_person_idx ON document_authors (person_id);
-- ─────────────────────────────────────────
-- document_chunks (text speech_chunks + vector embeddings)
-- ─────────────────────────────────────────
CREATE TABLE IF NOT EXISTS document_chunks (
-- Key: "{dok_id}:{chunk_index}", e.g. "HD02846:0"
id TEXT PRIMARY KEY,
doc_id TEXT NOT NULL REFERENCES documents(dok_id) ON DELETE CASCADE,
chunk_index INTEGER NOT NULL,
text TEXT NOT NULL,
embedding vector(384)
);
CREATE INDEX IF NOT EXISTS document_chunks_doc_idx ON document_chunks (doc_id);
CREATE INDEX IF NOT EXISTS document_chunks_embedding_idx ON document_chunks
USING hnsw (embedding vector_cosine_ops) WITH (m = 16, ef_construction = 64);
-- ─────────────────────────────────────────
-- document_proposals (one condensed proposal per row + its own embedding)
-- ─────────────────────────────────────────
CREATE TABLE IF NOT EXISTS document_proposals (
-- Key: "{doc_id}:{ordinal}", ordinal = 0-based position in the proposals_raw array
id TEXT PRIMARY KEY,
doc_id TEXT NOT NULL REFERENCES documents(doc_id) ON DELETE CASCADE,
ordinal INTEGER NOT NULL,
number TEXT,
text TEXT NOT NULL,
committee_recommendation TEXT, -- committee proposal (e.g. "Avslag")
chamber_decision TEXT, -- chamber decision (e.g. "Avslag"/"Bifall")
handled_in TEXT, -- committee report where handled
embedding vector(384)
);
CREATE INDEX IF NOT EXISTS document_proposals_doc_idx ON document_proposals (dok_id);
CREATE INDEX IF NOT EXISTS document_proposals_embedding_idx ON document_proposals
USING hnsw (embedding vector_cosine_ops) WITH (m = 16, ef_construction = 64);
-- ─────────────────────────────────────────
-- users / auth_tokens (optional zero-knowledge accounts)
-- ─────────────────────────────────────────
-- The server NEVER sees the password: the client stretches it with PBKDF2 and
-- sends only a derived auth key (stored bcrypt-hashed here) plus the DEK
-- wrapped by a client-side key. All owned content is ciphertext at rest.
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
username TEXT UNIQUE NOT NULL, -- stored lowercased
auth_hash TEXT NOT NULL, -- bcrypt(client-derived auth key)
kdf_salt TEXT NOT NULL, -- base64; client PBKDF2 salt
kdf_iterations INTEGER NOT NULL DEFAULT 600000,
wrapped_dek TEXT NOT NULL, -- "v1:..." DEK wrapped by client KEK
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
enc_settings TEXT, -- "v1:..." AI settings (incl. API key) under the DEK
settings_updated_at TIMESTAMPTZ
);
-- Added after the fact; kept here so an existing database picks them up.
ALTER TABLE users ADD COLUMN IF NOT EXISTS enc_settings TEXT;
ALTER TABLE users ADD COLUMN IF NOT EXISTS settings_updated_at TIMESTAMPTZ;
CREATE TABLE IF NOT EXISTS auth_tokens (
token_hash TEXT PRIMARY KEY, -- sha256(token); raw token lives only in the client
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_used_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ NOT NULL
);
CREATE INDEX IF NOT EXISTS auth_tokens_user_idx ON auth_tokens (user_id);
-- ─────────────────────────────────────────
-- chat_sessions (persistent chat history)
-- ─────────────────────────────────────────
CREATE TABLE IF NOT EXISTS chat_sessions (
id UUID PRIMARY KEY,
session_type TEXT NOT NULL CHECK (session_type IN ('general', 'mp')),
person_id TEXT REFERENCES people(person_id),
initial_speech_id TEXT,
llm_messages JSONB NOT NULL DEFAULT '[]',
turns JSONB NOT NULL DEFAULT '[]',
focus_ids TEXT[] NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_activity TIMESTAMPTZ NOT NULL DEFAULT NOW(),
-- Owned sessions (user_id set): all content lives encrypted in enc_payload
-- (llm_messages/turns/focus_ids AND person_id/initial_speech_id — which MP
-- you talked to is sensitive metadata); plaintext columns stay empty/NULL.
-- No 7-day expiry for owned rows.
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
enc_payload TEXT,
enc_title TEXT
);
CREATE INDEX IF NOT EXISTS chat_sessions_last_activity_idx ON chat_sessions (last_activity);
CREATE INDEX IF NOT EXISTS chat_sessions_user_idx ON chat_sessions (user_id, last_activity DESC);
ALTER TABLE chat_sessions ADD COLUMN IF NOT EXISTS user_id UUID REFERENCES users(id) ON DELETE CASCADE;
ALTER TABLE chat_sessions ADD COLUMN IF NOT EXISTS enc_payload TEXT;
ALTER TABLE chat_sessions ADD COLUMN IF NOT EXISTS enc_title TEXT;
-- ─────────────────────────────────────────
-- chat_snapshots (frozen, shareable chat views)
-- ─────────────────────────────────────────
CREATE TABLE IF NOT EXISTS chat_snapshots (
id UUID PRIMARY KEY,
session_type TEXT NOT NULL CHECK (session_type IN ('general', 'mp')),
person_id TEXT, -- for MP chats: used to show name/party in the snapshot view
turns JSONB NOT NULL DEFAULT '[]',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
-- Full tool-call history, so a forked snapshot resumes with the model's
-- context rather than only the rendered turns. Note that stat cards replay
-- the SQL stored in here, which is why retired column names need a
-- compatibility shim rather than a clean break.
llm_messages JSONB,
focus_ids TEXT[] DEFAULT '{}',
initial_speech_id TEXT,
last_activity TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS chat_snapshots_last_activity_idx ON chat_snapshots (last_activity);
-- ─────────────────────────────────────────
-- research_boards / research_threads (deep research)
-- ─────────────────────────────────────────
CREATE TABLE IF NOT EXISTS research_boards (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
title TEXT NOT NULL,
topic TEXT NOT NULL,
intro TEXT,
owner_session TEXT, -- per-browser owner (X-Session-Id); scopes the list
status TEXT NOT NULL DEFAULT 'new'
CHECK (status IN ('new', 'scouting', 'awaiting', 'digging', 'reporting', 'ready', 'failed')),
revision INTEGER NOT NULL DEFAULT 1,
target_depth INTEGER NOT NULL DEFAULT 3,
logic_version INTEGER NOT NULL DEFAULT 1,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
-- Encrypted boards (enc = TRUE): title/topic/intro and the threads'
-- title/question/why/findings/open_questions/leads hold "v1:..." ciphertext
-- under a per-board key. The board key is stored only wrapped by the
-- owner's DEK; background jobs receive the raw key via stdin (never the DB).
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
enc BOOLEAN NOT NULL DEFAULT FALSE,
wrapped_board_key TEXT,
-- Single regeneratable markdown report woven from the thread answers
-- (encrypted on enc boards).
report TEXT,
report_generated_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS research_boards_owner_idx ON research_boards (owner_session);
CREATE INDEX IF NOT EXISTS research_boards_user_idx ON research_boards (user_id);
ALTER TABLE research_boards ADD COLUMN IF NOT EXISTS user_id UUID REFERENCES users(id) ON DELETE CASCADE;
ALTER TABLE research_boards ADD COLUMN IF NOT EXISTS enc BOOLEAN NOT NULL DEFAULT FALSE;
ALTER TABLE research_boards ADD COLUMN IF NOT EXISTS wrapped_board_key TEXT;
ALTER TABLE research_boards ADD COLUMN IF NOT EXISTS report TEXT;
ALTER TABLE research_boards ADD COLUMN IF NOT EXISTS report_generated_at TIMESTAMPTZ;
CREATE TABLE IF NOT EXISTS research_threads (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
board_id UUID NOT NULL REFERENCES research_boards(id) ON DELETE CASCADE,
title TEXT NOT NULL,
question TEXT NOT NULL,
why TEXT NOT NULL DEFAULT '',
origin TEXT NOT NULL DEFAULT 'auto' CHECK (origin IN ('auto', 'seed')),
depth INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('proposed', 'active', 'archived')),
pinned BOOLEAN NOT NULL DEFAULT FALSE,
findings JSONB NOT NULL DEFAULT '[]',
open_questions JSONB NOT NULL DEFAULT '[]',
leads JSONB NOT NULL DEFAULT '[]',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
-- User's free-text steer for the thread (goes into trip prompts) and the
-- synthesized markdown answer; hints persist the discovery pass's search
-- suggestions until the thread's first trip. All three encrypted on enc
-- boards. answer_depth = depth when the answer was last synthesized
-- (depth > answer_depth means the answer is stale).
guidance TEXT,
answer TEXT,
answer_depth INTEGER NOT NULL DEFAULT 0,
hints JSONB NOT NULL DEFAULT '[]'
);
CREATE INDEX IF NOT EXISTS research_threads_board_idx ON research_threads (board_id);
ALTER TABLE research_threads ADD COLUMN IF NOT EXISTS guidance TEXT;
ALTER TABLE research_threads ADD COLUMN IF NOT EXISTS answer TEXT;
ALTER TABLE research_threads ADD COLUMN IF NOT EXISTS answer_depth INTEGER NOT NULL DEFAULT 0;
ALTER TABLE research_threads ADD COLUMN IF NOT EXISTS hints JSONB NOT NULL DEFAULT '[]';
-- ─────────────────────────────────────────
-- jobs / job_events (out-of-process background jobs)
-- ─────────────────────────────────────────
CREATE TABLE IF NOT EXISTS jobs (
id TEXT PRIMARY KEY,
kind TEXT NOT NULL,
board_id UUID REFERENCES research_boards(id) ON DELETE CASCADE,
status TEXT NOT NULL DEFAULT 'running'
CHECK (status IN ('running', 'done', 'failed', 'cancelled')),
params JSONB NOT NULL DEFAULT '{}',
progress JSONB NOT NULL DEFAULT '{}',
counts JSONB NOT NULL DEFAULT '{}',
errors JSONB NOT NULL DEFAULT '[]',
event_count INTEGER NOT NULL DEFAULT 0,
cancel_requested BOOLEAN NOT NULL DEFAULT FALSE,
pid INTEGER,
host TEXT,
started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
finished_at TIMESTAMPTZ,
last_heartbeat_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS jobs_status_idx ON jobs (status);
CREATE INDEX IF NOT EXISTS jobs_board_idx ON jobs (board_id);
CREATE TABLE IF NOT EXISTS job_events (
job_id TEXT NOT NULL REFERENCES jobs(id) ON DELETE CASCADE,
seq INTEGER NOT NULL,
event JSONB NOT NULL,
ts TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (job_id, seq)
);
-- ─────────────────────────────────────────
-- observability
-- ─────────────────────────────────────────
-- Deduplicated error store. Repeated failures bump `count` and `last_seen_at`
-- rather than inserting a new row, so a crash loop stays one line instead of
-- thousands. `fingerprint` is the dedup key (type + normalised traceback).
CREATE TABLE IF NOT EXISTS error_log (
id BIGSERIAL PRIMARY KEY,
fingerprint TEXT NOT NULL UNIQUE,
error_type TEXT NOT NULL,
first_seen_at TIMESTAMPTZ DEFAULT NOW(),
last_seen_at TIMESTAMPTZ DEFAULT NOW(),
count INTEGER NOT NULL DEFAULT 1,
model TEXT,
traceback TEXT,
detail JSONB
);
CREATE INDEX IF NOT EXISTS error_log_last_seen_idx ON error_log (last_seen_at DESC);
CREATE INDEX IF NOT EXISTS error_log_error_type_idx ON error_log (error_type);
-- Behavioural events (tool chosen, model used, latency). Append-only.
CREATE TABLE IF NOT EXISTS llm_events (
id BIGSERIAL PRIMARY KEY,
created_at TIMESTAMPTZ DEFAULT NOW(),
event_type TEXT NOT NULL,
detail JSONB
);
CREATE INDEX IF NOT EXISTS llm_events_created_idx ON llm_events (created_at DESC);
CREATE INDEX IF NOT EXISTS llm_events_type_idx ON llm_events (event_type);
-- Expression index: model lives inside the JSON payload but is grouped on constantly.
CREATE INDEX IF NOT EXISTS llm_events_model_idx ON llm_events ((detail ->> 'model'));
-- ─────────────────────────────────────────
-- evaluation harness
--
-- Populated by scripts/eval_harness.py. A "run" asks a fixed question set; each
-- answer is split into paragraphs and judged for whether its citations support it.
-- ─────────────────────────────────────────
CREATE TABLE IF NOT EXISTS eval_runs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
finished_at TIMESTAMPTZ,
label TEXT,
config JSONB,
num_questions INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS eval_questions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
run_id UUID REFERENCES eval_runs(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
question TEXT NOT NULL,
question_type TEXT,
answer TEXT,
tool_trace JSONB,
sources JSONB,
num_iterations INTEGER,
duration_ms INTEGER,
error TEXT,
complexity INTEGER
);
CREATE INDEX IF NOT EXISTS idx_eval_questions_run ON eval_questions (run_id);
CREATE TABLE IF NOT EXISTS eval_judgments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
question_id UUID REFERENCES eval_questions(id) ON DELETE CASCADE,
paragraph_idx INTEGER,
paragraph_text TEXT,
cited_indices INTEGER[],
verdict TEXT,
rationale TEXT,
judge_model TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
metadata_mismatch TEXT,
coverage_score DOUBLE PRECISION
);
CREATE INDEX IF NOT EXISTS idx_eval_judgments_q ON eval_judgments (question_id);
-- Whole conversations captured for later analysis. Only sessions whose first
-- message is prefixed "TEST " are recorded, so ordinary user chats are never stored here.
CREATE TABLE IF NOT EXISTS eval_conversations (
id BIGSERIAL PRIMARY KEY,
session_id TEXT NOT NULL,
turn_index INTEGER NOT NULL,
stream BOOLEAN NOT NULL DEFAULT FALSE,
started_at TIMESTAMPTZ NOT NULL,
finished_at TIMESTAMPTZ NOT NULL,
duration_s DOUBLE PRECISION,
iterations INTEGER,
has_error BOOLEAN NOT NULL DEFAULT FALSE,
doc JSONB NOT NULL
);
CREATE INDEX IF NOT EXISTS eval_conversations_session_idx ON eval_conversations (session_id, started_at);