Der Abruf warf bisher fast alles weg, was im XML stand. Neu ausgewertet werden Kategorien (<category>, dc:subject, Atom-term), content:encoded zusaetzlich zur kurzen description, Medien-URLs aus enclosure und Media RSS, Verweise aus dem Teaser-HTML und die Position im Feed beim ersten Sehen. Die Kategorien sind fuer Stufe 2 der wertvollste Posten: vom Verlag selbst vergebene Themenlabel, brauchbar als Vorfilter und als Prompt-Kontext. Gemessen an zwoelf Quellen tragen 189 von 814 Items Kategorien, 315 einen laengeren Volltext, 430 Medien. Ausserdem landen die rohen Antwortbytes gezippt in feed_abrufe, dedupliziert ueber ihren sha256. Der Parser wird sich weiter aendern; nur mit den Rohbytes laesst sich eine Verbesserung rueckwirkend auf die Historie anwenden - dieselbe Ueberlegung wie bei der Trennung von raw_items und codings. Kompression rund 22 %, etwa 20 kB je Abruf. Items ohne Titel werden weiter uebersprungen, jetzt aber gezaehlt. Bestandszeilen bekommen die neuen Felder beim naechsten Lauf nachgetragen, ohne als Revision zu zaehlen; der Inhalts-Hash bleibt dafuer auf Titel/Teaser/URL beschraenkt. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011ZAdZb3EifGb9nvQ5Zp2TE
213 lines
9.6 KiB
PL/PgSQL
213 lines
9.6 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',
|
|
position integer, -- Rang beim ersten Sehen
|
|
volltext text, -- content:encoded, falls zusaetzlich da
|
|
kategorien text[], -- <category> / dc:subject / Atom-term
|
|
medien jsonb, -- enclosure und Media RSS
|
|
links text[], -- Verweise aus dem Teaser-HTML
|
|
aktualisiert timestamptz, -- Atom <updated>, 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;
|