Der 2-Stunden-Takt verlor ganze Artikel. Ein RSS-Feed ist ein Fenster, kein Archiv: ist das Fenster kuerzer als der Poll-Abstand, fallen Eintraege zwischen zwei Abrufen heraus und sind nicht nachholbar. Gemessen am 2026-09-04 haelt zeit.de 0,6 h vor, spiegel und welt rund 5 h; von der Zeit sah man etwa jeden dritten Artikel. Der bedingte GET macht haeufiges Pollen billig, ein unveraenderter Abruf ist ein 304 ohne Body. Zweitens schrieb der Upsert Aenderungen nicht fort. Redaktionen aendern Ueberschriften nach der Veroeffentlichung; bei gleicher GUID lief das in ein `on conflict do nothing` und blieb unsichtbar. raw_items haelt jetzt den aktuellen Stand und zaehlt Revisionen, raw_item_versionen haelt jede Fassung einschliesslich der ersten. Erkannt wird das ueber einen Inhalts-Hash aus Titel, Teaser und URL. Die Migration zieht Bestandsdaten nach. Die SQL-Hashformel ist zeichengleich zu poller.inhalt_hash(), geprueft an 814 migrierten Items: null Scheinrevisionen beim ersten Lauf danach. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011ZAdZb3EifGb9nvQ5Zp2TE
175 lines
7.7 KiB
PL/PgSQL
175 lines
7.7 KiB
PL/PgSQL
-- 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',
|
|
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.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;
|
|
|
|
-- --------------------------------------------------------------------------
|
|
-- 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,
|
|
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;
|