Database connection

This commit is contained in:
irrlicht
2026-09-07 12:25:07 +02:00
parent 91931a47b0
commit d1bd1016f7
6 changed files with 531 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
# Nach .env kopieren und ausfuellen (chmod 600). .env ist gitignored.
#
# Dev-Datenbank im lokalen Podman-Container, ueber den gemappten Port.
# Im Produktivbetrieb stattdessen der Containername im gemeinsamen Netz,
# analog zu swp-01-ingest/ingest.env.example.
DATABASE_URL=postgresql://wurzelwerk:GEHEIM@127.0.0.1:5432/wurzelwerk
+3
View File
@@ -0,0 +1,3 @@
.venv/
__pycache__/
.env
Executable
+65
View File
@@ -0,0 +1,65 @@
#!/usr/bin/env python3
"""Rauchtest fuer den Datenbankzugang.
Meldet sich an, liest den Bestand und zeigt, worauf Phase 2 aufsetzt.
Nur SELECT - dieses Skript schreibt nichts.
python3 check_db.py
"""
import sys
from db import verbindung
def main():
with verbindung() as conn:
nutzer, datenbank, version = conn.execute(
"select current_user, current_database(), version()"
).fetchone()
print(f"verbunden als {nutzer}@{datenbank}")
print(f" {version.split(' on ')[0]}")
erw = [r[0] for r in conn.execute("select extname from pg_extension order by 1")]
print(f" Extensions: {', '.join(erw)}")
for fehlend in ("vector",):
if fehlend not in erw:
print(f" fehlt noch fuer Phase 2: {fehlend}")
print("\nTabellen")
for name, in conn.execute(
"""select relname from pg_class c join pg_namespace n on n.oid = c.relnamespace
where c.relkind = 'r' and n.nspname = 'public' order by relname"""
):
anzahl = conn.execute(f'select count(*) from "{name}"').fetchone()[0]
print(f" {name:22} {anzahl:>8}")
von, bis, slots, gesamt = conn.execute(
"select min(slot), max(slot), count(distinct slot), count(*) from raw_items"
).fetchone()
if not gesamt:
print("\nraw_items ist leer - Phase 1 hat hier noch nichts abgelegt.")
return 0
print(f"\nraw_items: {gesamt} Items in {slots} Slots, {von} bis {bis}")
# Volltextabdeckung entscheidet, worauf Dubletten und Embeddings
# ueberhaupt rechnen koennen - sie ist je Quelle sehr verschieden.
print("\nje Quelle")
print(f" {'quelle':<16}{'items':>7}{'volltext':>10}{'teaser':>8}{'revidiert':>11}")
for quelle, n, volltext, teaser, revidiert in conn.execute(
"""select quelle, count(*), count(volltext), count(teaser),
count(*) filter (where revisionen > 0)
from raw_items group by 1 order by 2 desc"""
):
print(f" {quelle:<16}{n:>7}{volltext:>10}{teaser:>8}{revidiert:>11}")
print("\njuengste Items")
for id_, quelle, titel in conn.execute(
"select id, quelle, titel from raw_items order by id desc limit 5"
):
print(f" {id_:>7} {quelle:<14}{titel[:60]}")
return 0
if __name__ == "__main__":
sys.exit(main())
+42
View File
@@ -0,0 +1,42 @@
"""Datenbankzugang fuer Phase 2.
Eine Stelle, die DATABASE_URL aufloest - aus der Umgebung oder aus .env
daneben. Zugangsdaten stehen nie im Code und nie in einem Argument, das in
der Prozessliste landet.
"""
import os
import pathlib
_ENV = pathlib.Path(__file__).with_name(".env")
def _aus_env_datei(pfad=_ENV):
"""Minimaler .env-Leser. Kein Shell-Parsing, keine Ersetzungen."""
werte = {}
if not pfad.exists():
return werte
for zeile in pfad.read_text(encoding="utf-8").splitlines():
zeile = zeile.strip()
if not zeile or zeile.startswith("#") or "=" not in zeile:
continue
schluessel, _, wert = zeile.partition("=")
werte[schluessel.strip()] = wert.strip().strip('"').strip("'")
return werte
def dsn():
url = os.environ.get("DATABASE_URL") or _aus_env_datei().get("DATABASE_URL")
if not url:
raise RuntimeError(
"DATABASE_URL fehlt. .env.example nach .env kopieren und ausfuellen."
)
return url
def verbindung(**kwargs):
"""Verbindung mit Autocommit aus. Aufrufer entscheidet ueber Transaktionen."""
import psycopg
kwargs.setdefault("connect_timeout", 5)
return psycopg.connect(dsn(), **kwargs)
+414
View File
@@ -0,0 +1,414 @@
# Wurzelwerkt — Phase 2: Aufbereitung
**Status:** Entwurf
**Gilt für:** Stinkwurzpresse / Projekt Wurzelwerkt
---
## 0. Zweck und Abgrenzung
Phase 2 reichert `raw_items` deterministisch an. Sie erzeugt keine Ereignisse und
keine Aussagen — das ist Phase 3. Sie erzeugt die Vorbedingungen dafür:
aufgelöste Akteure, Orte, Tonalität, Embeddings, und die Erkennung exakter
Übernahmen.
### Invarianten
1. **Kein LLM.** Alles regelbasiert, lexikonbasiert oder durch ein festes
Embedding-Modell. Kein generativer Schritt.
2. **Reproduzierbar.** Gleiche Eingabe + gleiche `coder_version` ⇒ bitgleiche
Ausgabe. Kein Sampling, keine Zeitabhängigkeit, keine Netzabfrage zur Laufzeit.
3. **Additiv.** `raw_items` wird nicht verändert. Ergebnisse gehen ausschließlich
nach `codings`.
4. **Verwerfbar.** Der gesamte Phase-2-Bestand muss löschbar und aus
`raw_items` neu baubar sein.
5. **Live.** Läuft auf Bebop, im Anschluss an jeden Poll-Lauf, innerhalb des
15-Minuten-Slots.
---
## 1. Datenmodell
Phase 2 schreibt in die vorhandene `codings`-Tabelle. Keine neuen Tabellen außer
den Lexika.
### 1.1 Ebenen
Je `raw_item` entsteht **eine** Coding-Zeile pro Ebene:
| `ebene` | Inhalt der `nutzlast` |
|--------------|----------------------------------------------|
| `dublette` | Zuordnung zu einer Übernahme-Gruppe |
| `akteure` | Liste aufgelöster Akteure mit Offsets |
| `geo` | Liste aufgelöster Orte mit Offsets |
| `tonalitaet` | Tonwerte auf Titel und Teaser |
| `embedding` | Vektor-Referenz und Modellkennung |
### 1.2 Erforderliche Schemaänderungen
```sql
-- Re-Runs müssen ersetzen, nicht duplizieren
ALTER TABLE codings
ADD CONSTRAINT codings_uniq UNIQUE (raw_item_id, coder_version, ebene);
CREATE INDEX codings_ebene_idx ON codings (ebene, kodiert_am);
CREATE INDEX codings_nutzlast_idx ON codings USING gin (nutzlast);
```
### 1.3 `coder_version`
Format: `p2-<ISO-Datum>-<hash8>`
Der Hash bildet: Regelwerk-Version, Gazetteer-Version, Akteurslexikon-Version,
Sentiment-Lexikon-Version, Embedding-Modell + Quantisierung, Normalisierungsregeln.
Ändert sich eines davon, ändert sich die Version. Alte Codings bleiben liegen.
Eine Tabelle `coder_versionen (version, komponenten jsonb, erstellt_am)` hält
auf, was hinter einem Hash steht. Ohne sie ist die Versionierung wertlos.
---
## 2. Eingabegrenzen
Phase 2 arbeitet auf `titel`, `teaser`, `kategorien` und — nur für Tonalität,
Akteure, Geo und Embedding — auf `volltext`, sofern vorhanden.
**Kein Phase-2-Ergebnis darf Volltext oder Teaser im Klartext enthalten.**
Offsets und Codes ja, Textausschnitte nein. Begründung: `codings` ist
Kandidat für den OpenData-Dump; Snippets darin würden die Veröffentlichungsgrenze
unterlaufen.
Ausnahme: die extrahierte Oberflächenform eines Akteurs oder Orts
(z. B. `"Robert Habeck"`) ist ein Eigenname, kein Werkausschnitt, und darf
gespeichert werden.
---
## 3. Modul: Exakte Dubletten (`ebene = 'dublette'`)
### Zweck
Agenturmeldungen (dpa, AFP, Reuters) erscheinen bei mehreren Quellen nahezu
identisch. Diese Fälle sollen *vor* Phase 3 zusammengefasst werden, damit das
LLM sie nicht mehrfach kodiert.
Dies ist **kein** thematisches Clustering. Nur near-exact.
### Verfahren
1. Normalisierung des Vergleichstexts (Titel + Teaser, oder Volltext falls
vorhanden): Unicode NFKC, Kleinschreibung, Entities aufgelöst, Whitespace
kollabiert, Anführungszeichen vereinheitlicht, redaktionelle Präfixe
(`"Eilmeldung:"`, `"+++ ... +++"`, Ortsmarken wie `"Berlin (dpa) -"`) entfernt.
2. SimHash (64 bit) über Wort-Trigramme des normalisierten Texts.
3. Kandidaten: gleiches Datum ±1 Tag, Hamming-Distanz ≤ 3.
4. Bestätigung: Jaccard über Trigramm-Mengen ≥ 0.85.
5. Union-Find über die bestätigten Paare ⇒ `uebernahme_gruppe` (UUID,
deterministisch aus der sortierten Menge der `raw_item_id` abgeleitet).
### Nutzlast
```json
{
"gruppe": "…",
"gruppengroesse": 4,
"leitartikel": 8814,
"simhash": "a3f1…",
"jaccard_min": 0.91,
"agentur_vermutet": "dpa"
}
```
`leitartikel` ist die `raw_item_id` mit dem frühesten `zuerst_gesehen`; bei
Gleichstand die kleinste ID. Rein deterministisch. Phase 3 kodiert nur den
Leitartikel; die übrigen Mitglieder erben die Kodierung, erscheinen aber
weiterhin einzeln als Erwähnung mit eigener Quelle.
`agentur_vermutet` aus Mustererkennung im Text (`"(dpa)"`, `"Reuters"` etc.),
nullable. Keine Vermutung ohne Textbeleg.
### Erwartung
Trefferquote gegen eine handgeprüfte Stichprobe von 200 Paaren: Präzision ≥ 0.98
ist Bedingung. Recall darf niedrig sein — verpasste Dubletten kosten nur
Rechenzeit in Phase 3, falsche verschmelzen unterschiedliche Vorgänge.
---
## 4. Modul: Akteursauflösung (`ebene = 'akteure'`)
### Zweck
Erkennung und Auflösung von Personen, Organisationen, Parteien, Staaten und
Ämtern auf stabile interne IDs.
### Lexikon
Neue Tabelle:
```sql
CREATE TABLE akteure (
id text PRIMARY KEY, -- z.B. 'per:habeck_robert'
typ text NOT NULL, -- person|organisation|partei|staat|amt
name text NOT NULL, -- kanonische Form
aliase text[] NOT NULL DEFAULT '{}',
land text, -- ISO-3166-1 alpha-3
rolle text, -- z.B. 'GOV','LEG','OPP','JUD','MIL','MED','BUS','NGO'
gueltig_von date,
gueltig_bis date,
wikidata_qid text,
quelle text NOT NULL, -- woher der Eintrag stammt
notiz text
);
CREATE INDEX akteure_aliase_idx ON akteure USING gin (aliase);
```
**Zeitliche Gültigkeit ist nicht optional.** „Der Bundeskanzler" bezeichnet
je nach `pubdate` verschiedene Personen. Auflösung von Ämtern erfolgt
immer gegen das Datum des Items.
Erstbefüllung: Wikidata-Extrakt (Bundestagsabgeordnete, Kabinettsmitglieder,
Ministerpräsidenten, DAX-Vorstände, Staaten, internationale Organisationen)
plus manuelle Ergänzung. Als versionierter Dump im Repo, nicht als Live-Abfrage.
### Verfahren
1. NER über den Text (spaCy `de_core_news_lg` oder Flair). Modellkennung geht
in die `coder_version`.
2. Kandidatensuche im Lexikon über Alias-Match (normalisiert) und Fuzzy-Match
(Levenshtein-Grenze abhängig von der Länge).
3. Disambiguierung über Kontext: gleichzeitig genannte Akteure, Ressort der
Quelle, Land. Regelbasiert und dokumentiert, keine Heuristik ohne Testfall.
4. Ämter (`"der Kanzler"`, `"die Innenministerin"`) über Amtstabelle gegen
`pubdate` auflösen.
5. Nicht auflösbare Erwähnungen bleiben als unaufgelöste Oberflächenform
erhalten — sie sind das Rohmaterial für die Lexikonpflege.
### Nutzlast
```json
{
"erkannt": [
{"id": "per:habeck_robert", "form": "Robert Habeck",
"typ": "person", "rolle": "GOV", "feld": "titel",
"start": 0, "ende": 13, "konfidenz": 0.97, "methode": "alias"},
{"id": null, "form": "Verband der Regionalversorger",
"typ": "organisation", "feld": "teaser",
"start": 44, "ende": 73, "konfidenz": 0.4, "methode": "ner_unaufgeloest"}
],
"ner_modell": "de_core_news_lg-3.7.0",
"lexikon_version": "2026-09-01"
}
```
### Betrieb
Eine Sicht `akteure_unaufgeloest` aggregiert unaufgelöste Formen nach Häufigkeit.
Wöchentliche Durchsicht; häufige Formen wandern ins Lexikon. Das ist der einzige
laufende Handarbeitsposten der Phase.
---
## 5. Modul: Geokodierung (`ebene = 'geo'`)
### Zweck
Auflösung von Ortsnennungen auf Koordinaten und Verwaltungsebenen.
### Gazetteer
GeoNames-Dump für DE, AT, CH plus `countryInfo` und `admin1CodesASCII`, lokal
und versioniert. Zusätzlich eine Ausschlussliste für notorische Fehlauslöser:
Straßennamen mit Ortsbezug, Zeitungsnamen (`"Frankfurter Allgemeine"`),
Produktnamen, Personennamen die Ortsnamen sind.
```sql
CREATE TABLE geo_orte (
geonames_id bigint PRIMARY KEY,
name text NOT NULL,
aliase text[] NOT NULL DEFAULT '{}',
land text NOT NULL,
adm1 text,
adm2 text,
lat double precision NOT NULL,
lon double precision NOT NULL,
einwohner bigint,
feature_class char(1),
feature_code text
);
```
### Verfahren
1. Ortskandidaten aus dem NER-Durchlauf (Typ `LOC`/`GPE`) übernehmen — kein
zweiter NER-Lauf.
2. Ausschlussliste anwenden.
3. Gazetteer-Match. Bei Mehrdeutigkeit Rangfolge: (a) im Text genanntes
übergeordnetes Gebiet, (b) Ressort/Regionalbezug der Quelle, (c) Einwohnerzahl.
Die Regel ist fest und dokumentiert, nicht gelernt.
4. Ableitung eines `hauptort` je Item: der Ort mit der höchsten Gewichtung aus
Position (Titel > Teaser > Volltext) und Nennungshäufigkeit. Bei Gleichstand
der spezifischste (höchste Verwaltungsebene).
### Nutzlast
```json
{
"orte": [
{"geonames_id": 2879139, "form": "Leipzig", "land": "DEU",
"adm1": "13", "lat": 51.33962, "lon": 12.37129,
"feld": "titel", "start": 12, "ende": 19, "konfidenz": 0.93}
],
"hauptort": 2879139,
"gazetteer_version": "2026-08-15"
}
```
Items ohne Ortsbezug bekommen `"hauptort": null` und eine leere Liste — die
Coding-Zeile entsteht trotzdem, damit „nicht geokodiert" von „kein Ort gefunden"
unterscheidbar bleibt.
---
## 6. Modul: Tonalität (`ebene = 'tonalitaet'`)
### Zweck
Ein Tonwert je Item als Aggregatsignal.
### Warnung zur Interpretation
Der Wert ist **nicht** GDELTs `AvgTone` und darf nicht als solcher ausgegeben
werden. Er misst lexikalische Polarität eines deutschen Sentimentlexikons auf
einem kurzen Text. Bei Nachrichtensprache ist die Aussagekraft je Einzelitem
gering; erst im Aggregat über Quelle und Zeit wird sie brauchbar. Das gehört
in die Felddokumentation und auf die Website, nicht in eine Fußnote.
### Verfahren
SentiWS + GerVADER, beide lokal, beide versioniert. Negation und Intensivierer
über Abhängigkeitsparse. Getrennte Werte für Titel und Teaser, weil
Titelzuspitzung ein eigenständig interessantes Signal ist.
### Nutzlast
```json
{
"titel": {"ton": -2.41, "pos": 0.8, "neg": 3.2, "polar_anteil": 0.18},
"teaser": {"ton": -0.87, "pos": 1.9, "neg": 2.8, "polar_anteil": 0.11},
"skala": "sentiws-2.0/gervader-1.1",
"wortzahl": {"titel": 9, "teaser": 41}
}
```
Wertebereich −10 bis +10, dokumentiert. `polar_anteil` (Anteil polarer Wörter)
dient als Zuverlässigkeitsindikator — niedrige Werte bedeuten, dass der Ton
auf wenigen Treffern beruht.
---
## 7. Modul: Embeddings (`ebene = 'embedding'`)
### Zweck
Vorfilter für die Kandidatengenerierung in Phase 3b. Kein Selbstzweck, kein
Clustering in Phase 2.
### Verfahren
Modell: ein mehrsprachiges Satz-Embedding mit guter Deutschabdeckung
(`intfloat/multilingual-e5-large` oder `jina-embeddings-v3`), lokal auf Bebop,
CPU-Inferenz, feste Version. Eingabe: Titel + Teaser, auf feste Tokenzahl
gekürzt. Deterministisch — kein Dropout, feste Präzision.
Speicherung: `pgvector`.
```sql
CREATE EXTENSION IF NOT EXISTS vector;
CREATE TABLE item_embeddings (
raw_item_id bigint NOT NULL,
coder_version text NOT NULL,
vektor vector(1024) NOT NULL,
PRIMARY KEY (raw_item_id, coder_version)
);
CREATE INDEX item_embeddings_hnsw
ON item_embeddings USING hnsw (vektor vector_cosine_ops);
```
Der Vektor liegt bewusst **nicht** in `codings.nutzlast` — JSONB-Vektoren sind
weder indizierbar noch platzsparend. Die Coding-Zeile hält nur die Kennung:
```json
{"modell": "multilingual-e5-large", "dim": 1024, "gespeichert": true}
```
---
## 8. Ausführung
### Auslöser
Nach jedem Poll-Lauf für die in diesem Slot neuen oder geänderten Items.
Zusätzlich ein Backfill-Modus für den Gesamtbestand bei
`coder_version`-Wechsel.
### Reihenfolge
```
dublette ──► akteure ──► geo ──► tonalitaet ──► embedding
│ ▲
└────────┘ (geo nutzt NER-Ausgabe von akteure)
```
Module sind bis auf diese eine Abhängigkeit unabhängig und einzeln neu baubar.
### Idempotenz
`INSERT … ON CONFLICT (raw_item_id, coder_version, ebene) DO UPDATE`.
Ein Modul zweimal laufen zu lassen ändert nichts.
### Fehlerverhalten
Ein fehlgeschlagenes Modul blockiert die anderen nicht. Fehler landen in einer
`p2_laeufe`-Tabelle analog zu `poll_laeufe`: Slot, Modul, Anzahl verarbeitet,
Anzahl fehlgeschlagen, Dauer, Fehlermeldung. Keine stillen Ausfälle.
### Budget
Ziel: vollständige Verarbeitung eines 15-Minuten-Slots (~200–400 Items bei
40 Feeds) in unter 3 Minuten Wall-Clock auf Bebop. Wird das gerissen, ist das
Embedding-Modul der erste Kandidat zur Auslagerung in einen Nachlauf.
---
## 9. Öffentliche Darstellung
Die Phase-2-Seite zeigt, was die Anreicherung tut, ohne Volltext zu berühren:
- Karte der Hauptorte des Tages (aus `geo`)
- Häufigste Akteure der Woche, mit Trend
- Übernahmegruppen: welche Meldung von wie vielen Häusern gleichlautend
ausgespielt wurde, mit Zeitversatz. Das ist der publizistisch interessanteste
Teil.
- Tonalität je Quelle im Zeitverlauf — mit sichtbarem Hinweis auf die
Interpretationsgrenzen aus Abschnitt 6.
Kein Modul ist ohne die Grenzen seiner Aussagekraft darzustellen.
---
## 10. Abnahmekriterien
| Kriterium | Schwelle |
|---|---|
| Dubletten: Präzision auf 200er-Stichprobe | ≥ 0.98 |
| Akteure: aufgelöste Nennungen an allen Nennungen | ≥ 0.75 |
| Akteure: Präzision auf 200er-Stichprobe | ≥ 0.95 |
| Geo: Hauptort korrekt auf 200er-Stichprobe | ≥ 0.85 |
| Laufzeit je Slot | < 3 min |
| Bitgleicher Neuaufbau bei gleicher `coder_version` | verpflichtend |
| Kein Text > 100 Zeichen in `codings.nutzlast` | verpflichtend, automatisch geprüft |
Die letzte Zeile ist als Constraint oder Test zu erzwingen, nicht als Vorsatz.
+1
View File
@@ -0,0 +1 @@
psycopg[binary]>=3.2