Database connection
This commit is contained in:
@@ -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
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
.venv/
|
||||||
|
__pycache__/
|
||||||
|
.env
|
||||||
Executable
+65
@@ -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())
|
||||||
@@ -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)
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
psycopg[binary]>=3.2
|
||||||
Reference in New Issue
Block a user