Initial import of Politrack, a self-hosted service that tracks Austrian politicians from federal to municipal level - roles, votes, money flows, statements, meetings, social media and legal proceedings - with every claim linked to its sources. - backend: FastAPI + Postgres/pgvector; adapter framework (fetch -> parse -> load -> enrich) with robots.txt/TDM-aware fetching, a raw-response archive for reparsing, and a claim/evidence model with corroboration - sources: Parliament, Wikidata, EU Parliament/Commission, Council of Ministers, Rechnungshof party reports and donations, procurement, RTR media transparency, political ad libraries, WKStA, news feeds, Wikipedia edits, Bluesky, Mastodon, public Telegram channels, YouTube and X (official API) - web: Next.js frontend in German and English with typed API client - mcp: MCP server for AI agents and RAG - docs: architecture, data model, sources, legal notes, Unraid deployment and contributor guides; Docker Compose setup for Unraid Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
501 lines
26 KiB
SQL
501 lines
26 KiB
SQL
-- Politrack schema (v2). See docs/architecture/01-design-from-scratch.md and docs/data-model.md.
|
|
--
|
|
-- Every user-visible fact is a CLAIM backed by >= 1 EVIDENCE row pointing to a DOCUMENT (a URL)
|
|
-- published by a SOURCE. Typed facet tables (role, vote_position, money_flow, ...) add structure.
|
|
-- Entities carry any number of external IDENTIFIERS. Raw responses are indexed in FETCH.
|
|
-- All objects carry COMMENTs so agents can discover semantics from the catalog.
|
|
|
|
CREATE EXTENSION IF NOT EXISTS vector;
|
|
CREATE EXTENSION IF NOT EXISTS pg_trgm;
|
|
CREATE EXTENSION IF NOT EXISTS unaccent;
|
|
|
|
-- ---------------------------------------------------------------------------
|
|
-- Sources, raw archive, documents
|
|
-- ---------------------------------------------------------------------------
|
|
|
|
CREATE TABLE source (
|
|
id SERIAL PRIMARY KEY,
|
|
slug TEXT NOT NULL UNIQUE,
|
|
name TEXT NOT NULL,
|
|
kind TEXT NOT NULL CHECK (kind IN ('official', 'ngo', 'media', 'social', 'aggregator', 'user', 'derived')),
|
|
homepage TEXT,
|
|
license TEXT,
|
|
access_method TEXT NOT NULL CHECK (access_method IN ('api', 'bulk', 'rss', 'scrape', 'manual', 'derived')),
|
|
store_fulltext BOOLEAN NOT NULL DEFAULT FALSE,
|
|
keep_raw BOOLEAN NOT NULL DEFAULT TRUE,
|
|
default_trust SMALLINT NOT NULL DEFAULT 50 CHECK (default_trust BETWEEN 0 AND 100),
|
|
tos_notes TEXT,
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
);
|
|
COMMENT ON TABLE source IS 'A publisher (parliament API, a newspaper, a social network...). Rows are synced from the SourceSpec objects in backend/politrack/sources (single source of truth).';
|
|
COMMENT ON COLUMN source.store_fulltext IS 'FALSE when licence/ToS or a TDM reservation forbids keeping body text: only title, short excerpt and link are stored.';
|
|
COMMENT ON COLUMN source.keep_raw IS 'FALSE when raw responses must not be archived (e.g. TDM-reserved publishers).';
|
|
|
|
CREATE TABLE fetch_log (
|
|
id BIGSERIAL PRIMARY KEY,
|
|
adapter TEXT NOT NULL,
|
|
source_id INT REFERENCES source(id),
|
|
request_fp TEXT NOT NULL,
|
|
url TEXT NOT NULL,
|
|
method TEXT NOT NULL DEFAULT 'GET',
|
|
request JSONB NOT NULL DEFAULT '{}'::jsonb,
|
|
status INT,
|
|
sha256 TEXT,
|
|
size INT,
|
|
content_type TEXT,
|
|
etag TEXT,
|
|
last_modified TEXT,
|
|
stored BOOLEAN NOT NULL DEFAULT FALSE,
|
|
tdm_reserved BOOLEAN NOT NULL DEFAULT FALSE,
|
|
fetched_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
parsed_sha256 TEXT,
|
|
parser_version INT,
|
|
error TEXT
|
|
);
|
|
CREATE INDEX fetch_fp_idx ON fetch_log (request_fp, fetched_at DESC);
|
|
CREATE INDEX fetch_adapter_idx ON fetch_log (adapter, fetched_at DESC);
|
|
COMMENT ON TABLE fetch_log IS 'Raw archive index: one row per HTTP fetch. Bodies live gzip-compressed under POLITRACK_RAW_DIR/<sha256[:2]>/<sha256>.gz when stored=TRUE. Used for conditional requests, change detection and re-parsing.';
|
|
|
|
CREATE TABLE story (
|
|
id BIGSERIAL PRIMARY KEY,
|
|
title TEXT NOT NULL,
|
|
first_seen TIMESTAMPTZ,
|
|
last_seen TIMESTAMPTZ,
|
|
document_count INT NOT NULL DEFAULT 0,
|
|
source_count INT NOT NULL DEFAULT 0
|
|
);
|
|
COMMENT ON TABLE story IS 'Cluster of news documents about the same event. Reported by several independent outlets = corroborated.';
|
|
|
|
CREATE TABLE document (
|
|
id BIGSERIAL PRIMARY KEY,
|
|
source_id INT NOT NULL REFERENCES source(id),
|
|
url TEXT NOT NULL UNIQUE,
|
|
title TEXT,
|
|
doc_type TEXT NOT NULL DEFAULT 'web_page',
|
|
lang TEXT DEFAULT 'de',
|
|
published_at TIMESTAMPTZ,
|
|
retrieved_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
excerpt TEXT,
|
|
full_text TEXT,
|
|
archive_url TEXT,
|
|
fetch_id BIGINT REFERENCES fetch_log(id) ON DELETE SET NULL,
|
|
story_id BIGINT REFERENCES story(id) ON DELETE SET NULL,
|
|
meta JSONB NOT NULL DEFAULT '{}'::jsonb
|
|
);
|
|
CREATE INDEX document_source_idx ON document (source_id, published_at DESC);
|
|
CREATE INDEX document_published_idx ON document (published_at DESC);
|
|
CREATE INDEX document_story_idx ON document (story_id);
|
|
CREATE INDEX document_title_trgm ON document USING gin (title gin_trgm_ops);
|
|
COMMENT ON TABLE document IS 'One URL (article, API record, protocol, post, register entry). Evidence rows point here; url is what users click to verify a claim.';
|
|
COMMENT ON COLUMN document.doc_type IS 'article | press_release | api_record | protocol | social_post | register_entry | web_page | pdf | dataset_row';
|
|
|
|
-- ---------------------------------------------------------------------------
|
|
-- Entities
|
|
-- ---------------------------------------------------------------------------
|
|
|
|
CREATE TABLE person (
|
|
id SERIAL PRIMARY KEY,
|
|
slug TEXT NOT NULL UNIQUE,
|
|
full_name TEXT NOT NULL,
|
|
given_name TEXT,
|
|
family_name TEXT,
|
|
honorific_prefix TEXT,
|
|
honorific_suffix TEXT,
|
|
birth_date DATE,
|
|
death_date DATE,
|
|
gender TEXT,
|
|
photo_url TEXT,
|
|
summary_de TEXT,
|
|
summary_en TEXT,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
);
|
|
CREATE INDEX person_name_trgm ON person USING gin (full_name gin_trgm_ops);
|
|
COMMENT ON TABLE person IS 'A politician: anyone holding or having held a party, parliamentary, government or municipal function.';
|
|
|
|
CREATE TABLE organization (
|
|
id SERIAL PRIMARY KEY,
|
|
slug TEXT NOT NULL UNIQUE,
|
|
name TEXT NOT NULL,
|
|
short_name TEXT,
|
|
kind TEXT NOT NULL CHECK (kind IN ('party', 'parliamentary_group', 'parliament', 'government', 'ministry',
|
|
'state_government', 'municipality', 'public_body', 'company', 'ngo',
|
|
'media', 'lobby', 'court', 'authority', 'eu_institution', 'other')),
|
|
parent_id INT REFERENCES organization(id),
|
|
color TEXT,
|
|
logo_url TEXT,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
);
|
|
CREATE INDEX organization_name_trgm ON organization USING gin (name gin_trgm_ops);
|
|
COMMENT ON TABLE organization IS 'Parties, parliamentary groups, chambers, ministries, public bodies, companies, media owners, lobby organisations.';
|
|
|
|
CREATE TABLE identifier (
|
|
entity_type TEXT NOT NULL CHECK (entity_type IN ('person', 'organization')),
|
|
entity_id INT NOT NULL,
|
|
scheme TEXT NOT NULL,
|
|
value TEXT NOT NULL,
|
|
source_slug TEXT,
|
|
verified BOOLEAN NOT NULL DEFAULT TRUE,
|
|
PRIMARY KEY (entity_type, scheme, value)
|
|
);
|
|
CREATE INDEX identifier_entity_idx ON identifier (entity_type, entity_id);
|
|
COMMENT ON TABLE identifier IS 'External ids: scheme parlament (PAD_INTERN), wikidata (QID), ep (MEP id), bluesky (DID), mastodon (acct), telegram (channel), youtube (channel id), uid (ATU...), firmenbuch, rtr (Rechtsträger id), eu_transparency (register id)...';
|
|
|
|
CREATE TABLE alias (
|
|
entity_type TEXT NOT NULL CHECK (entity_type IN ('person', 'organization')),
|
|
entity_id INT NOT NULL,
|
|
alias TEXT NOT NULL,
|
|
alias_norm TEXT NOT NULL,
|
|
PRIMARY KEY (entity_type, entity_id, alias_norm)
|
|
);
|
|
CREATE INDEX alias_norm_idx ON alias (entity_type, alias_norm);
|
|
COMMENT ON TABLE alias IS 'Name variants for entity resolution and linking (titles stripped, legal forms normalised for organisations).';
|
|
|
|
CREATE TABLE social_account (
|
|
id SERIAL PRIMARY KEY,
|
|
person_id INT REFERENCES person(id) ON DELETE CASCADE,
|
|
organization_id INT REFERENCES organization(id) ON DELETE CASCADE,
|
|
platform TEXT NOT NULL,
|
|
handle TEXT,
|
|
url TEXT NOT NULL,
|
|
verified BOOLEAN NOT NULL DEFAULT FALSE,
|
|
source_slug TEXT,
|
|
UNIQUE (platform, url)
|
|
);
|
|
COMMENT ON COLUMN social_account.verified IS 'TRUE when the account comes from an official source (parliament profile, party/official website, platform verification); Wikidata-only handles are FALSE until confirmed.';
|
|
|
|
-- ---------------------------------------------------------------------------
|
|
-- Topics, claims, evidence
|
|
-- ---------------------------------------------------------------------------
|
|
|
|
CREATE TABLE topic (
|
|
id SERIAL PRIMARY KEY,
|
|
slug TEXT NOT NULL UNIQUE,
|
|
name_de TEXT NOT NULL,
|
|
name_en TEXT NOT NULL,
|
|
keywords TEXT[] NOT NULL DEFAULT '{}'
|
|
);
|
|
COMMENT ON TABLE topic IS 'Bilingual topic taxonomy; the first 18 mirror the Austrian Parliament''s THEMEN.';
|
|
|
|
CREATE TABLE claim (
|
|
id BIGSERIAL PRIMARY KEY,
|
|
claim_type TEXT NOT NULL CHECK (claim_type IN (
|
|
'role', 'vote', 'donation', 'money_flow', 'statement', 'appearance', 'meeting', 'travel',
|
|
'side_job', 'legal_proceeding', 'lobbying', 'media_mention', 'career',
|
|
'parliamentary_question', 'government_decision', 'wiki_edit', 'declaration', 'ownership',
|
|
'affiliation', 'party_finance', 'other')),
|
|
fingerprint TEXT NOT NULL UNIQUE,
|
|
person_id INT REFERENCES person(id) ON DELETE CASCADE,
|
|
organization_id INT REFERENCES organization(id) ON DELETE CASCADE,
|
|
counterpart_person_id INT REFERENCES person(id),
|
|
counterpart_org_id INT REFERENCES organization(id),
|
|
occurred_at TIMESTAMPTZ,
|
|
valid_from DATE,
|
|
valid_to DATE,
|
|
summary_de TEXT NOT NULL,
|
|
summary_en TEXT,
|
|
data JSONB NOT NULL DEFAULT '{}'::jsonb,
|
|
status TEXT NOT NULL DEFAULT 'reported' CHECK (status IN ('reported', 'corroborated', 'disputed', 'retracted')),
|
|
sensitive BOOLEAN NOT NULL DEFAULT FALSE,
|
|
review_status TEXT NOT NULL DEFAULT 'published' CHECK (review_status IN ('pending', 'published', 'rejected')),
|
|
reviewed_by TEXT,
|
|
reviewed_at TIMESTAMPTZ,
|
|
merged_into BIGINT REFERENCES claim(id) ON DELETE SET NULL,
|
|
created_by TEXT NOT NULL,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
);
|
|
CREATE INDEX claim_person_idx ON claim (person_id, claim_type, occurred_at DESC);
|
|
CREATE INDEX claim_org_idx ON claim (organization_id, claim_type, occurred_at DESC);
|
|
CREATE INDEX claim_cp_org_idx ON claim (counterpart_org_id) WHERE counterpart_org_id IS NOT NULL;
|
|
CREATE INDEX claim_type_time_idx ON claim (claim_type, occurred_at DESC);
|
|
CREATE INDEX claim_merged_idx ON claim (merged_into) WHERE merged_into IS NOT NULL;
|
|
CREATE INDEX claim_review_idx ON claim (review_status) WHERE review_status <> 'published';
|
|
COMMENT ON TABLE claim IS 'An atomic statement about a person and/or organization, never shown without evidence. Identity (fingerprint) is computed by the Loader from resolved entities and type-specific fields, so the same fact from several sources is one claim.';
|
|
COMMENT ON COLUMN claim.status IS 'reported = 1 source; corroborated = >= 2 independent sources; disputed = contradicting evidence; retracted.';
|
|
COMMENT ON COLUMN claim.sensitive IS 'Legal matters: UI and agents must show "Es gilt die Unschuldsvermutung" unless legal stage = convicted_final and not disputed.';
|
|
COMMENT ON COLUMN claim.merged_into IS 'Set when found to describe the same fact as another claim (corroboration); reads skip merged claims.';
|
|
|
|
CREATE TABLE claim_evidence (
|
|
id BIGSERIAL PRIMARY KEY,
|
|
claim_id BIGINT NOT NULL REFERENCES claim(id) ON DELETE CASCADE,
|
|
document_id BIGINT NOT NULL REFERENCES document(id) ON DELETE CASCADE,
|
|
stance TEXT NOT NULL DEFAULT 'supports' CHECK (stance IN ('supports', 'contradicts', 'mentions')),
|
|
quote TEXT,
|
|
locator TEXT NOT NULL DEFAULT '',
|
|
extracted_by TEXT NOT NULL,
|
|
confidence REAL NOT NULL DEFAULT 1.0,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
UNIQUE (claim_id, document_id, locator)
|
|
);
|
|
CREATE INDEX claim_evidence_doc_idx ON claim_evidence (document_id);
|
|
|
|
CREATE TABLE topic_assignment (
|
|
topic_id INT NOT NULL REFERENCES topic(id) ON DELETE CASCADE,
|
|
claim_id BIGINT NOT NULL REFERENCES claim(id) ON DELETE CASCADE,
|
|
confidence REAL NOT NULL DEFAULT 1.0,
|
|
method TEXT NOT NULL,
|
|
PRIMARY KEY (topic_id, claim_id)
|
|
);
|
|
CREATE INDEX topic_assignment_claim_idx ON topic_assignment (claim_id);
|
|
|
|
-- ---------------------------------------------------------------------------
|
|
-- Facets
|
|
-- ---------------------------------------------------------------------------
|
|
|
|
CREATE TABLE role (
|
|
claim_id BIGINT PRIMARY KEY REFERENCES claim(id) ON DELETE CASCADE,
|
|
person_id INT NOT NULL REFERENCES person(id) ON DELETE CASCADE,
|
|
organization_id INT REFERENCES organization(id),
|
|
party_id INT REFERENCES organization(id),
|
|
role TEXT NOT NULL,
|
|
level TEXT NOT NULL CHECK (level IN ('eu', 'federal', 'state', 'district', 'municipal', 'party', 'other')),
|
|
region TEXT,
|
|
start_date DATE,
|
|
end_date DATE
|
|
);
|
|
CREATE INDEX role_person_idx ON role (person_id);
|
|
CREATE INDEX role_party_idx ON role (party_id);
|
|
CREATE INDEX role_org_idx ON role (organization_id);
|
|
COMMENT ON TABLE role IS 'Mandates and functions. role = ''mp'' for members of a chamber (organization_id = chamber), otherwise the office title. end_date NULL = open.';
|
|
|
|
CREATE TABLE vote_event (
|
|
id BIGSERIAL PRIMARY KEY,
|
|
event_key TEXT NOT NULL UNIQUE,
|
|
chamber_id INT REFERENCES organization(id),
|
|
item_ref TEXT,
|
|
item_title TEXT NOT NULL,
|
|
stage TEXT,
|
|
voted_on DATE,
|
|
result TEXT CHECK (result IN ('accepted', 'rejected', 'unknown')),
|
|
roll_call BOOLEAN NOT NULL DEFAULT FALSE,
|
|
url TEXT,
|
|
protocol_url TEXT,
|
|
raw_text TEXT
|
|
);
|
|
CREATE INDEX vote_event_date_idx ON vote_event (voted_on DESC);
|
|
COMMENT ON TABLE vote_event IS 'A vote. Most Austrian votes are by standing up: only faction positions exist. roll_call = TRUE: individual positions exist.';
|
|
|
|
CREATE TABLE vote_position (
|
|
claim_id BIGINT PRIMARY KEY REFERENCES claim(id) ON DELETE CASCADE,
|
|
vote_event_id BIGINT NOT NULL REFERENCES vote_event(id) ON DELETE CASCADE,
|
|
organization_id INT REFERENCES organization(id),
|
|
person_id INT REFERENCES person(id),
|
|
party_id INT REFERENCES organization(id),
|
|
position TEXT NOT NULL CHECK (position IN ('for', 'against', 'abstain', 'absent')),
|
|
CHECK (organization_id IS NOT NULL OR person_id IS NOT NULL)
|
|
);
|
|
CREATE INDEX vote_position_event_idx ON vote_position (vote_event_id);
|
|
CREATE INDEX vote_position_person_idx ON vote_position (person_id) WHERE person_id IS NOT NULL;
|
|
COMMENT ON COLUMN vote_position.party_id IS 'For individual (roll-call) votes: the voter''s faction at the time.';
|
|
|
|
CREATE TABLE money_flow (
|
|
claim_id BIGINT PRIMARY KEY REFERENCES claim(id) ON DELETE CASCADE,
|
|
kind TEXT NOT NULL CHECK (kind IN ('donation', 'contract', 'government_ad', 'political_ad', 'subsidy',
|
|
'party_funding', 'party_ad', 'loan', 'side_income', 'sponsorship',
|
|
'other')),
|
|
payer_name TEXT,
|
|
payer_org_id INT REFERENCES organization(id),
|
|
payer_person_id INT REFERENCES person(id),
|
|
payee_name TEXT,
|
|
payee_org_id INT REFERENCES organization(id),
|
|
payee_person_id INT REFERENCES person(id),
|
|
amount NUMERIC(16, 2),
|
|
amount_category TEXT,
|
|
currency TEXT NOT NULL DEFAULT 'EUR',
|
|
paid_on DATE,
|
|
period TEXT,
|
|
reference TEXT,
|
|
category TEXT
|
|
);
|
|
CREATE INDEX money_flow_payer_idx ON money_flow (payer_org_id);
|
|
CREATE INDEX money_flow_payee_idx ON money_flow (payee_org_id);
|
|
CREATE INDEX money_flow_kind_idx ON money_flow (kind, paid_on DESC);
|
|
COMMENT ON TABLE money_flow IS 'Donations (Rechnungshof lists and party reports), public party funding, sponsoring, ads in party media (party_ad), loans, public contracts (BVergG core data), government advertising (RTR), political ads (Google/Meta archives). category = CPV code, ad medium category etc.';
|
|
|
|
CREATE TABLE event (
|
|
id BIGSERIAL PRIMARY KEY,
|
|
event_key TEXT NOT NULL UNIQUE,
|
|
kind TEXT NOT NULL CHECK (kind IN ('parliament_session', 'committee', 'press_conference', 'tv_appearance',
|
|
'interview', 'party_event', 'public_event', 'foreign_trip', 'meeting',
|
|
'hearing', 'government_meeting', 'other')),
|
|
title TEXT NOT NULL,
|
|
starts_at TIMESTAMPTZ,
|
|
ends_at TIMESTAMPTZ,
|
|
location_name TEXT,
|
|
country TEXT,
|
|
url TEXT,
|
|
description TEXT,
|
|
organization_id INT REFERENCES organization(id),
|
|
source_id INT REFERENCES source(id)
|
|
);
|
|
CREATE INDEX event_time_idx ON event (starts_at);
|
|
COMMENT ON TABLE event IS 'Something with a time and place (sessions, hearings, trips, meetings). Participation is a claim with an event_participant facet.';
|
|
|
|
CREATE TABLE event_participant (
|
|
claim_id BIGINT PRIMARY KEY REFERENCES claim(id) ON DELETE CASCADE,
|
|
event_id BIGINT NOT NULL REFERENCES event(id) ON DELETE CASCADE,
|
|
person_id INT REFERENCES person(id),
|
|
organization_id INT REFERENCES organization(id),
|
|
participation TEXT NOT NULL DEFAULT 'participant'
|
|
);
|
|
CREATE INDEX event_participant_event_idx ON event_participant (event_id);
|
|
|
|
CREATE TABLE statement (
|
|
claim_id BIGINT PRIMARY KEY REFERENCES claim(id) ON DELETE CASCADE,
|
|
person_id INT REFERENCES person(id),
|
|
context TEXT NOT NULL CHECK (context IN ('parliament_speech', 'interview', 'tv', 'radio', 'social_post',
|
|
'press_release', 'event_speech', 'op_ed', 'testimony', 'other')),
|
|
platform TEXT,
|
|
medium TEXT,
|
|
title TEXT,
|
|
said_at TIMESTAMPTZ,
|
|
excerpt TEXT,
|
|
url TEXT NOT NULL
|
|
);
|
|
CREATE INDEX statement_person_idx ON statement (person_id, said_at DESC);
|
|
|
|
CREATE TABLE legal_proceeding (
|
|
claim_id BIGINT PRIMARY KEY REFERENCES claim(id) ON DELETE CASCADE,
|
|
person_id INT REFERENCES person(id),
|
|
organization_id INT REFERENCES organization(id),
|
|
stage TEXT NOT NULL CHECK (stage IN ('allegation_reported', 'investigation', 'immunity_waiver_requested',
|
|
'immunity_waived', 'immunity_refused', 'indicted', 'trial',
|
|
'convicted_not_final', 'convicted_final', 'acquitted',
|
|
'proceedings_dropped', 'diversion')),
|
|
offense TEXT,
|
|
authority TEXT,
|
|
case_ref TEXT,
|
|
stage_date DATE
|
|
);
|
|
COMMENT ON COLUMN legal_proceeding.stage IS 'Only convicted_final (and not disputed) is a settled finding of guilt; all other stages carry "Es gilt die Unschuldsvermutung".';
|
|
|
|
-- ---------------------------------------------------------------------------
|
|
-- Retrieval (RAG)
|
|
-- ---------------------------------------------------------------------------
|
|
|
|
CREATE TABLE chunk (
|
|
id BIGSERIAL PRIMARY KEY,
|
|
document_id BIGINT REFERENCES document(id) ON DELETE CASCADE,
|
|
claim_id BIGINT REFERENCES claim(id) ON DELETE CASCADE,
|
|
chunk_index INT NOT NULL DEFAULT 0,
|
|
text TEXT NOT NULL,
|
|
lang TEXT DEFAULT 'de',
|
|
tsv TSVECTOR GENERATED ALWAYS AS (to_tsvector('german', text)) STORED,
|
|
embedding vector(1024),
|
|
embedding_model TEXT,
|
|
CHECK (document_id IS NOT NULL OR claim_id IS NOT NULL)
|
|
);
|
|
CREATE UNIQUE INDEX chunk_uniq ON chunk (COALESCE(document_id, 0), COALESCE(claim_id, 0), chunk_index);
|
|
CREATE INDEX chunk_tsv_idx ON chunk USING gin (tsv);
|
|
CREATE INDEX chunk_embedding_idx ON chunk USING hnsw (embedding vector_cosine_ops);
|
|
COMMENT ON TABLE chunk IS 'Text units for hybrid search (German FTS + bge-m3 embeddings); each links to a claim and/or document for citations.';
|
|
|
|
-- ---------------------------------------------------------------------------
|
|
-- Operations
|
|
-- ---------------------------------------------------------------------------
|
|
|
|
CREATE TABLE run (
|
|
id BIGSERIAL PRIMARY KEY,
|
|
adapter TEXT NOT NULL,
|
|
mode TEXT NOT NULL DEFAULT 'fetch',
|
|
started_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
finished_at TIMESTAMPTZ,
|
|
status TEXT NOT NULL DEFAULT 'running' CHECK (status IN ('running', 'ok', 'partial', 'error')),
|
|
stats JSONB NOT NULL DEFAULT '{}'::jsonb,
|
|
error TEXT
|
|
);
|
|
CREATE INDEX run_adapter_idx ON run (adapter, started_at DESC);
|
|
|
|
CREATE TABLE state (
|
|
adapter TEXT NOT NULL,
|
|
key TEXT NOT NULL,
|
|
value JSONB NOT NULL,
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
PRIMARY KEY (adapter, key)
|
|
);
|
|
COMMENT ON TABLE state IS 'Cursors of incremental adapters (last date, last id...).';
|
|
|
|
CREATE TABLE app_user (
|
|
id SERIAL PRIMARY KEY,
|
|
external_subject TEXT NOT NULL UNIQUE,
|
|
email TEXT,
|
|
locale TEXT NOT NULL DEFAULT 'de',
|
|
excluded_source_ids INT[] NOT NULL DEFAULT '{}',
|
|
topic_ids INT[] NOT NULL DEFAULT '{}',
|
|
followed_person_ids INT[] NOT NULL DEFAULT '{}',
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
);
|
|
|
|
-- ---------------------------------------------------------------------------
|
|
-- Views
|
|
-- ---------------------------------------------------------------------------
|
|
|
|
CREATE VIEW person_identifier AS
|
|
SELECT p.id AS person_id,
|
|
max(i.value) FILTER (WHERE i.scheme = 'parlament') AS parlament_pad,
|
|
max(i.value) FILTER (WHERE i.scheme = 'wikidata') AS wikidata_qid,
|
|
max(i.value) FILTER (WHERE i.scheme = 'ep') AS ep_id
|
|
FROM person p LEFT JOIN identifier i ON i.entity_type = 'person' AND i.entity_id = p.id
|
|
GROUP BY p.id;
|
|
COMMENT ON VIEW person_identifier IS 'Convenience: the most common external ids of a person in columns.';
|
|
|
|
-- Activity status, derived from roles.
|
|
-- Parliament mandates/functions are authoritative (exact end dates) -> verified.
|
|
-- Other sources (mostly Wikidata) often lack end dates: an open-ended office of a living person
|
|
-- counts as current if it started within the last 15 years; flagged verified = false.
|
|
CREATE VIEW person_status AS
|
|
WITH current_roles AS (
|
|
SELECT r.person_id, r.level, r.role, r.party_id, r.start_date, c.created_by
|
|
FROM role r JOIN claim c ON c.id = r.claim_id
|
|
WHERE c.merged_into IS NULL AND (r.end_date IS NULL OR r.end_date >= current_date)
|
|
AND (c.created_by LIKE 'parlament%%' OR c.created_by LIKE 'europarl%%' OR r.end_date IS NOT NULL OR r.level = 'party'
|
|
OR (r.start_date IS NOT NULL AND r.start_date >= current_date - interval '15 years'))
|
|
)
|
|
SELECT p.id AS person_id,
|
|
CASE
|
|
WHEN p.death_date IS NOT NULL THEN 'deceased'
|
|
WHEN EXISTS (SELECT 1 FROM current_roles cr WHERE cr.person_id = p.id AND cr.level <> 'party') THEN 'active'
|
|
WHEN EXISTS (SELECT 1 FROM current_roles cr WHERE cr.person_id = p.id AND cr.level = 'party') THEN 'party_member'
|
|
ELSE 'former'
|
|
END AS status,
|
|
EXISTS (SELECT 1 FROM current_roles cr WHERE cr.person_id = p.id AND cr.level <> 'party'
|
|
AND (cr.created_by LIKE 'parlament%%' OR cr.created_by LIKE 'europarl%%')) AS verified,
|
|
(SELECT max(r.end_date) FROM role r JOIN claim c ON c.id = r.claim_id
|
|
WHERE r.person_id = p.id AND c.merged_into IS NULL AND r.level <> 'party') AS last_office_end,
|
|
(SELECT array_agg(DISTINCT cr.role) FROM current_roles cr WHERE cr.person_id = p.id AND cr.level <> 'party')
|
|
AS current_roles,
|
|
(SELECT array_agg(DISTINCT cr.party_id) FROM current_roles cr WHERE cr.person_id = p.id AND cr.party_id IS NOT NULL)
|
|
AS current_party_ids
|
|
FROM person p;
|
|
COMMENT ON VIEW person_status IS 'active = current office (verified = confirmed by Parliament/EP data); party_member = only party membership; former; deceased.';
|
|
|
|
CREATE VIEW claim_with_evidence AS
|
|
SELECT c.id AS claim_id, c.claim_type, c.summary_de, c.summary_en, c.occurred_at, c.valid_from, c.valid_to,
|
|
c.status, c.sensitive, p.full_name AS person_name, p.slug AS person_slug,
|
|
o.name AS organization_name, o.slug AS organization_slug,
|
|
jsonb_agg(jsonb_build_object('url', d.url, 'title', d.title, 'source', s.slug, 'source_name', s.name,
|
|
'published_at', d.published_at, 'quote', e.quote, 'stance', e.stance)
|
|
ORDER BY d.published_at) AS evidence
|
|
FROM claim c
|
|
LEFT JOIN person p ON p.id = c.person_id
|
|
LEFT JOIN organization o ON o.id = c.organization_id
|
|
JOIN claim_evidence e ON e.claim_id = c.id
|
|
JOIN document d ON d.id = e.document_id
|
|
JOIN source s ON s.id = d.source_id
|
|
WHERE c.merged_into IS NULL AND c.review_status = 'published'
|
|
GROUP BY c.id, p.id, o.id;
|
|
COMMENT ON VIEW claim_with_evidence IS 'Published claims with all evidence links - the easiest entry point for agents.';
|
|
|
|
-- Read-only role for agents / BI. Password set by `politrack migrate` from POLITRACK_READER_PASSWORD.
|
|
DO $$
|
|
BEGIN
|
|
IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'politrack_reader') THEN
|
|
CREATE ROLE politrack_reader LOGIN;
|
|
END IF;
|
|
END $$;
|
|
GRANT USAGE ON SCHEMA public TO politrack_reader;
|
|
GRANT SELECT ON ALL TABLES IN SCHEMA public TO politrack_reader;
|
|
REVOKE SELECT ON app_user FROM politrack_reader;
|
|
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO politrack_reader;
|