-- 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 15 min abgerundet, GDELT-kompatibel sprache text not null default 'de', roh jsonb not null, -- alle geparsten Feldwerte, unveraendert unique (quelle, item_key) ); 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); -- -------------------------------------------------------------------------- -- 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, 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 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;