-- Schema fuer den Ingest. -- -- Grundregel: raw_items ist append-only und wird nie ueberschrieben. -- Kodierungen sind eine getrennte Stufe mit eigener Versionierung, damit die -- gesamte Historie neu kodiert werden kann, ohne einen Feed erneut abzurufen. create extension if not exists pg_trgm; -- -------------------------------------------------------------------------- -- Quellen: aus ingest/sources.toml synchronisiert, nie von Hand pflegen. -- -------------------------------------------------------------------------- create table if not exists quellen ( id text primary key, name text not null, typ text not null check (typ in ('rss', 'telegram')), url text not null, land text not null, traeger text, ressort text, lizenz_status text not null check (lizenz_status in ('offen','gesperrt','ungeprueft')), robots_geprueft_am date, aktiv boolean not null default false, notiz text, -- Poller-Zustand, nicht aus der TOML etag text, last_modified text, zuletzt_geprueft timestamptz, zuletzt_geaendert timestamptz ); comment on column quellen.zuletzt_geprueft is 'letzter Abruf, auch wenn 304'; comment on column quellen.zuletzt_geaendert is 'letzter Abruf mit neuen Items'; -- -------------------------------------------------------------------------- -- Rohdaten. Append-only. RSS-Feeds haben kein Archiv: was hier fehlt, ist -- unwiederbringlich weg. -- -------------------------------------------------------------------------- create table if not exists raw_items ( id bigserial primary key, quelle text not null references quellen(id), item_key text not null, -- guid/link, nur innerhalb der Quelle eindeutig url text, titel text not null, titel_norm text not null, -- normalisiert, fuer Clustering teaser text, autor text, pubdate timestamptz, -- Angabe der Quelle, kann fehlen/luegen abgerufen_am timestamptz not null default now(), slot timestamptz not null, -- auf den Poll-Takt abgerundet sprache text not null default 'de', position integer, -- Rang beim ersten Sehen volltext text, -- content:encoded, falls zusaetzlich da kategorien text[], -- / dc:subject / Atom-term medien jsonb, -- enclosure und Media RSS links text[], -- Verweise aus dem Teaser-HTML aktualisiert timestamptz, -- Atom , neben pubdate inhalt_hash text not null, -- md5 ueber titel|teaser|url revisionen integer not null default 0, zuletzt_geaendert timestamptz, roh jsonb not null, -- alle geparsten Feldwerte, unveraendert unique (quelle, item_key) ); comment on column raw_items.kategorien is 'Vom Verlag selbst vergebene Themenlabel - Vorfilter und Prompt-Kontext'; comment on column raw_items.position is 'Rang im Feed beim ersten Sehen. Spaeter nicht fortgeschrieben'; comment on column raw_items.revisionen is 'Wie oft die Quelle das Item nach der Erstveroeffentlichung geaendert hat'; create index if not exists raw_items_slot_idx on raw_items (slot desc); create index if not exists raw_items_pubdate_idx on raw_items (pubdate desc nulls last); create index if not exists raw_items_quelle_idx on raw_items (quelle, abgerufen_am desc); -- Titel-Clustering laeuft ueber Trigramm-Aehnlichkeit in SQL, nicht in Python create index if not exists raw_items_titelnorm_trgm on raw_items using gin (titel_norm gin_trgm_ops); create index if not exists raw_items_revidiert_idx on raw_items (zuletzt_geaendert desc) where revisionen > 0; -- -------------------------------------------------------------------------- -- Fassungen. Redaktionen aendern Ueberschriften nach der Veroeffentlichung; -- frueher lief das in ein `on conflict do nothing` und blieb unsichtbar. -- raw_items haelt den aktuellen Stand, diese Tabelle jede Fassung - auch die -- erste, damit die Historie vollstaendig ist. -- -- Der Datensatz existiert sonst nirgends: GDELT sieht jede URL genau einmal. -- -------------------------------------------------------------------------- create table if not exists raw_item_versionen ( id bigserial primary key, raw_item_id bigint not null references raw_items(id) on delete cascade, gesehen_am timestamptz not null default now(), slot timestamptz not null, titel text not null, teaser text, url text, inhalt_hash text not null, unique (raw_item_id, inhalt_hash) ); create index if not exists raw_item_versionen_item_idx on raw_item_versionen (raw_item_id, gesehen_am); -- Items, deren Ueberschrift sich geaendert hat, mit erster und letzter Fassung drop view if exists titel_revisionen; create view titel_revisionen as select r.id, r.quelle, r.url, r.revisionen, count(*) as fassungen, (array_agg(v.titel order by v.gesehen_am))[1] as erste_fassung, (array_agg(v.titel order by v.gesehen_am desc))[1] as letzte_fassung, min(v.gesehen_am) as erstmals, max(v.gesehen_am) as zuletzt from raw_items r join raw_item_versionen v on v.raw_item_id = r.id where r.revisionen > 0 group by r.id, r.quelle, r.url, r.revisionen; -- -------------------------------------------------------------------------- -- Rohe Feed-Antworten. Der Parser wird sich aendern; nur mit den Rohbytes -- laesst sich eine spaetere Verbesserung rueckwirkend auf die Historie -- anwenden. Dieselbe Ueberlegung wie bei der Trennung raw_items / codings. -- -- Dedupliziert ueber (quelle, koerper_hash): ein unveraenderter Feed wird -- nicht zweimal abgelegt, und ein 304 hat ohnehin keinen Koerper. -- -------------------------------------------------------------------------- create table if not exists feed_abrufe ( id bigserial primary key, quelle text not null references quellen(id), slot timestamptz not null, abgerufen_am timestamptz not null default now(), http_status integer, header jsonb, -- Date, Age, Cache-Control, ... koerper_hash text not null, -- sha256 ueber die Rohbytes koerper bytea not null, -- gzip bytes_roh integer, bytes_gz integer, unique (quelle, koerper_hash) ); create index if not exists feed_abrufe_quelle_idx on feed_abrufe (quelle, abgerufen_am desc); -- -------------------------------------------------------------------------- -- Kodierungen. Eine Zeile pro (Item, Kodierer-Version). Die Nutzlast bleibt -- absichtlich jsonb, solange nicht gemessen ist, bis zu welcher CAMEO-Ebene -- das gewaehlte Modell traegt. -- -------------------------------------------------------------------------- create table if not exists codings ( id bigserial primary key, raw_item_id bigint not null references raw_items(id) on delete cascade, coder_version text not null, -- z.B. 'qwen3-14b-q4/prompt-3' kodiert_am timestamptz not null default now(), ebene text not null -- erreichte CAMEO-Ebene check (ebene in ('keine','quad','root','base','event')), konfidenz real, nutzlast jsonb not null, unique (raw_item_id, coder_version) ); create index if not exists codings_version_idx on codings (coder_version, kodiert_am desc); -- Arbeitsvorrat fuer Yantra: was diese Kodierer-Version noch nicht gesehen hat. -- Aufruf: select * from unkodiert('qwen3-14b-q4/prompt-3') limit 500; create or replace function unkodiert(v text) returns setof raw_items language sql stable as $$ select r.* from raw_items r where not exists ( select 1 from codings c where c.raw_item_id = r.id and c.coder_version = v ) order by r.id $$; -- -------------------------------------------------------------------------- -- Betriebsprotokoll. Damit der Watchdog eine stillstehende Quelle bemerkt, -- bevor Tage fehlen. -- -------------------------------------------------------------------------- create table if not exists poll_laeufe ( id bigserial primary key, quelle text not null references quellen(id), slot timestamptz not null, begonnen_am timestamptz not null default now(), dauer_ms integer, http_status integer, -- 304 = unveraendert items_gesehen integer not null default 0, items_neu integer not null default 0, items_geaendert integer not null default 0, items_uebersprungen integer not null default 0, -- ohne Titel, unbrauchbar bytes_roh integer, fehler text ); create index if not exists poll_laeufe_slot_idx on poll_laeufe (slot desc); create index if not exists poll_laeufe_quelle_idx on poll_laeufe (quelle, begonnen_am desc); -- Welche aktive Quelle hat seit ueber sechs Stunden (24 verpasste Slots bei -- 15-Minuten-Takt) nichts Neues geliefert? create or replace view quellen_stillstand as select q.id, q.name, max(r.abgerufen_am) as letztes_item, now() - max(r.abgerufen_am) as stille from quellen q left join raw_items r on r.quelle = q.id where q.aktiv group by q.id, q.name having max(r.abgerufen_am) is null or now() - max(r.abgerufen_am) > interval '6 hours' order by stille desc nulls first;