Dublettenprüfung
This commit is contained in:
@@ -1,3 +1,4 @@
|
|||||||
.venv/
|
.venv/
|
||||||
__pycache__/
|
__pycache__/
|
||||||
.env
|
.env
|
||||||
|
stichprobe-*.txt
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
# Befunde aus dem Dublettenmodul
|
||||||
|
|
||||||
|
Gemessen am Bestand vom 4.–7.9.2026: 3407 Items, 12 Quellen, 106 Slots.
|
||||||
|
|
||||||
|
## Das Verfahren der Spezifikation findet keine Übernahmen
|
||||||
|
|
||||||
|
67 Gruppen, 147 Mitglieder, **null davon über Häuser hinweg**. Alle gefundenen
|
||||||
|
Gruppen sind Wiederholungen innerhalb eines Feeds.
|
||||||
|
|
||||||
|
Das ist kein Schwellenproblem, das sich durch Nachjustieren lösen ließe. Ein
|
||||||
|
vollständiger Vergleich aller 599.004 hausübergreifenden Paare des stärksten
|
||||||
|
Tages (6.9., 1162 vergleichbare Items) ergibt:
|
||||||
|
|
||||||
|
| Jaccard über Titel+Teaser | Paare über Häuser |
|
||||||
|
|---|---|
|
||||||
|
| ≥ 0.85 (Schwelle der Spec) | **0** |
|
||||||
|
| ≥ 0.7 | 1 |
|
||||||
|
| ≥ 0.5 | 3 |
|
||||||
|
| ≥ 0.3 | 8 |
|
||||||
|
|
||||||
|
Der höchste erreichte Wert im gesamten Tag liegt bei 0.65. Die Schwelle wird
|
||||||
|
nie berührt.
|
||||||
|
|
||||||
|
Die Hamming-Grenze ist dabei unschuldig: von 224 Paaren mit Jaccard ≥ 0.85
|
||||||
|
verwirft sie 6. Der Vorfilter über SimHash-Bänder kostet also fast nichts —
|
||||||
|
er ist gut, er hat nur nichts zu tun.
|
||||||
|
|
||||||
|
## Warum: der Titel wird übernommen, der Teaser nicht
|
||||||
|
|
||||||
|
Die Übernahmen sind da, sie sehen nur anders aus als angenommen. Beispiele
|
||||||
|
vom 6.9., alle mit **zeichengleichem Titel** nach Normalisierung:
|
||||||
|
|
||||||
|
```
|
||||||
|
zeit / handelsblatt Wahl in Sachsen-Anhalt: Klüssendorf: Frage nach SPD-Vorsitz …
|
||||||
|
zeit / handelsblatt Ukraine-Krieg: Selenskyj mit wenig Hoffnung auf baldigen Frieden
|
||||||
|
heise / tagesschau Deutsche Isar-Aerospace-Rakete erfolgreich ins All gestartet
|
||||||
|
handelsblatt / tagesschau +++ Iran-Krieg +++: Tote und Verletzte bei neuen Angriffen
|
||||||
|
```
|
||||||
|
|
||||||
|
Die Häuser übernehmen die Agenturüberschrift wörtlich und schreiben den
|
||||||
|
Teaser selbst. Werden beide in einen Sack geworfen, verdünnt der Teaser genau
|
||||||
|
das Signal, das trägt: dieselben Paare landen bei Jaccard 0.29 bis 0.38.
|
||||||
|
|
||||||
|
Gemessen, wenn **nur der Titel** verglichen wird (6.9., 1144 Items mit
|
||||||
|
mindestens fünf Titelwörtern):
|
||||||
|
|
||||||
|
| Kriterium | Paare | davon über Häuser |
|
||||||
|
|---|---|---|
|
||||||
|
| Titel zeichengleich | 61 | **22** |
|
||||||
|
| Titel-Jaccard ≥ 0.75 | 16 | **9** |
|
||||||
|
|
||||||
|
31 hausübergreifende Übernahmen an einem Tag statt null. Die Differenz
|
||||||
|
zwischen „zeichengleich" und „≥ 0.75" ist fast immer ein vorangestelltes
|
||||||
|
Ressortkürzel des Hauses: `Raumfahrt: Deutsche Isar-Aerospace-Rakete …` gegen
|
||||||
|
`Deutsche Isar-Aerospace-Rakete …`, oder `Notfälle:` gegen `Flugverkehr:` vor
|
||||||
|
derselben dpa-Zeile.
|
||||||
|
|
||||||
|
## Vorschlag
|
||||||
|
|
||||||
|
Vergleichsbasis auf den **Titel** umstellen, den Teaser nur noch als
|
||||||
|
Bestätigung heranziehen, wo er auf beiden Seiten vorliegt. Zusätzlich das
|
||||||
|
führende Ressortpräfix (`Wort:` am Anfang) in der Normalisierung abtrennen —
|
||||||
|
danach fällt ein Teil der 0.75er-Fälle in die Zeichengleichheit.
|
||||||
|
|
||||||
|
Das ist eine Änderung an Abschnitt 3 der Spezifikation, nicht an ihrer Absicht:
|
||||||
|
„near-exact, kein thematisches Clustering" bleibt. Die Abnahmekriterien wären
|
||||||
|
neu zu setzen, weil sich mit der Basis auch die Grundgesamtheit ändert.
|
||||||
|
|
||||||
|
## Umgesetzt, mit diesem Ergebnis
|
||||||
|
|
||||||
|
Der Vorschlag ist gebaut und ist jetzt die Vorgabe (`--basis titel`, dazu das
|
||||||
|
abgetrennte Ressortkürzel). Auf demselben Bestand:
|
||||||
|
|
||||||
|
| | Titel + Teaser | Titel |
|
||||||
|
|---|---|---|
|
||||||
|
| Gruppen gesamt | 67 | 135 |
|
||||||
|
| Mitglieder | 147 | 290 |
|
||||||
|
| **Gruppen über mehrere Häuser** | **0** | **63** |
|
||||||
|
| Items in solchen Gruppen | 0 | 143 |
|
||||||
|
|
||||||
|
Gruppengrößen: 240 Paare, 33 Dreier, 12 Vierer, 5 Fünfer. Die größte ist
|
||||||
|
dieselbe Zeile über den Start der Isar-Aerospace-Rakete bei handelsblatt,
|
||||||
|
heise, tagesschau und zeit.
|
||||||
|
|
||||||
|
Auf dem gemessenen Tag (6.9.) erreicht das Verfahren 42 von 45
|
||||||
|
hausübergreifenden Paaren mit Jaccard ≥ 0.70. Die Schwellen kosten dabei
|
||||||
|
nichts: an der Jaccard-Schwelle gehen 0 Paare verloren, an der
|
||||||
|
Hamming-Grenze 0. Die drei fehlenden scheitern am SimHash-Bandvorfilter —
|
||||||
|
bei sechs bis acht Trigrammen je Titel ist SimHash ein grobes Werkzeug. Ein
|
||||||
|
Trigramm-Index als zweiter Kandidatenweg würde sie einfangen.
|
||||||
|
|
||||||
|
Die Schwelle selbst ist unkritisch: zwischen Jaccard 0.70 und 0.85 liegt auf
|
||||||
|
diesem Bestand kein einziges Paar. Nach dem Abtrennen des Ressortkürzels sind
|
||||||
|
die Übernahmen entweder zeichengleich oder deutlich verschieden.
|
||||||
|
|
||||||
|
## Nebenbefund: Volltext hilft hier nicht
|
||||||
|
|
||||||
|
Auf den ersten Blick läge nahe, den Volltext zu nehmen, wo er da ist. Genau
|
||||||
|
das verbietet sich: die Abdeckung ist ungleich (faz 311/489, heise 174/196,
|
||||||
|
aber derstandard, welt, dlf, taz, sz jeweils 0), und ein Paar aus einem Haus
|
||||||
|
mit und einem ohne Volltext ist der häufigste Fall überhaupt. Eine je Item
|
||||||
|
verschiedene Vergleichsbasis macht genau die Paare unauffindbar, um die es
|
||||||
|
geht.
|
||||||
|
|
||||||
|
## Nebenbefund: `agentur_vermutet` greift kaum
|
||||||
|
|
||||||
|
Zwei Belege im gesamten Bestand, beide `reuters`. Die Agenturkennung steht in
|
||||||
|
RSS-Titeln und -Teasern praktisch nie; sie taucht erst im Volltext oder in der
|
||||||
|
Bildunterschrift auf. Das Feld bleibt, liefert aber auf RSS-Basis fast immer
|
||||||
|
`null`.
|
||||||
|
|
||||||
|
## Was beim Aufräumen weggefallen ist
|
||||||
|
|
||||||
|
Nach den Experimenten einmal gemessen, welche Regel auf den 3407 Items
|
||||||
|
überhaupt jemals greift:
|
||||||
|
|
||||||
|
| Regel | Treffer |
|
||||||
|
|---|---|
|
||||||
|
| Ressortkürzel abtrennen | 1870 |
|
||||||
|
| Anführungszeichen/Striche vereinheitlichen | 654 |
|
||||||
|
| `+++ … +++` entfernen | 8 |
|
||||||
|
| NFKC | 4 |
|
||||||
|
| `Eilmeldung:` entfernen | **0** |
|
||||||
|
| Ortsmarke `Berlin (dpa) -` entfernen | **0** |
|
||||||
|
| HTML-Tags entfernen | **0** (auch im Teaser) |
|
||||||
|
| Entities auflösen im Titel | **0** |
|
||||||
|
|
||||||
|
Entfernt wurden `_EILMELDUNG` und `_ORTSMARKE`. Beide stehen so in Abschnitt 3
|
||||||
|
der Spezifikation, greifen aber auf RSS-Titeln nie — die Ortsmarke steht im
|
||||||
|
Volltext einer Agenturmeldung, nicht in der Überschrift. Kommt Volltext als
|
||||||
|
Quelle hinzu, gehören sie zurück.
|
||||||
|
|
||||||
|
Das Entfernen von HTML-Tags bleibt trotz null Treffern: Phase 1 zieht die
|
||||||
|
`links` aus dem Teaser-HTML, HTML ist im Feed also grundsätzlich zu erwarten.
|
||||||
|
Der doppelte Entity-Durchlauf (vor *und* nach dem Tag-Entfernen) ist auf einen
|
||||||
|
reduziert.
|
||||||
|
|
||||||
|
Ebenfalls raus: der ungenutzte `anzahl`-Parameter von `baender()`, zwei
|
||||||
|
ungenutzte Argumente von `stichprobe()`, der `version`-Parameter von
|
||||||
|
`eintragen()`, ein `__main__`-Block in `version.py`, der eine Version ohne
|
||||||
|
Moduleinstellungen ausgab und damit nie die echte.
|
||||||
|
|
||||||
|
Zusammengelegt: `lade()` hatte am Ende selbst gruppiert, und `main()` rechnete
|
||||||
|
dieselbe Gruppierung anschließend ein zweites Mal. Beides steckt jetzt in
|
||||||
|
`lade_und_gruppiere()` — beim Slot-Lauf gehört es ohnehin zusammen, weil sich
|
||||||
|
erst an den Gruppen zeigt, wieviel geladen werden muss.
|
||||||
|
|
||||||
|
Zwei Kommentarblöcke zur Vergleichsbasis widersprachen sich nach der
|
||||||
|
Umstellung; der ältere behauptete noch „verglichen wird immer Titel + Teaser".
|
||||||
|
|
||||||
|
Die Schwellen selbst sind alle belegt. Von 778 Kandidatenpaaren verwirft das
|
||||||
|
Datumsfenster 223, die Regel „gleiches Haus, anderer Tag" 48 und die
|
||||||
|
Hamming-Grenze 330; 177 werden bestätigt. Nur die Jaccard-Bestätigung verwirft
|
||||||
|
nichts mehr — sie bleibt als Präzisionszusage, weil SimHash kollidieren kann.
|
||||||
|
Der Kettenschnitt trifft genau eine von 135 Komponenten und bleibt aus
|
||||||
|
demselben Grund.
|
||||||
@@ -17,6 +17,10 @@ Spezifikation: [`phase_02_spec.md`](phase_02_spec.md).
|
|||||||
| `migrations/` | Schemaänderungen, aufsteigend anzuwenden |
|
| `migrations/` | Schemaänderungen, aufsteigend anzuwenden |
|
||||||
| `db.py` | Auflösung von `DATABASE_URL`, Verbindung |
|
| `db.py` | Auflösung von `DATABASE_URL`, Verbindung |
|
||||||
| `check_db.py` | Rauchtest: Anmeldung, Bestand, fehlende Voraussetzungen |
|
| `check_db.py` | Rauchtest: Anmeldung, Bestand, fehlende Voraussetzungen |
|
||||||
|
| `normalisierung.py` | Textnormalisierung und Trigramme, gemeinsam für alle Module |
|
||||||
|
| `version.py` | `coder_version` aus den Komponenten |
|
||||||
|
| `dubletten.py` | Modul 1: exakte Übernahmen |
|
||||||
|
| `test_dubletten.py` | Testfälle dazu, ohne Datenbank |
|
||||||
|
|
||||||
## Einrichtung
|
## Einrichtung
|
||||||
|
|
||||||
@@ -43,24 +47,46 @@ psql "$DATABASE_URL" -c 'select name, angewandt_am from schema_migrationen order
|
|||||||
Die Zählung trägt das Präfix `p2-`, weil die Ingest-Migrationen eine eigene
|
Die Zählung trägt das Präfix `p2-`, weil die Ingest-Migrationen eine eigene
|
||||||
haben und beide auf dieselbe Datenbank laufen.
|
haben und beide auf dieselbe Datenbank laufen.
|
||||||
|
|
||||||
**Migrationen brauchen einen anderen Zugang als der Betrieb.** Die Tabellen
|
Zwei Migrationen brauchen einen Superuser, weil sie Rechte vergeben, die sie
|
||||||
gehören `postgres`, nicht `wurzelwerk`; `alter table` scheitert sonst mit
|
selbst noch nicht haben: `p2-002` (`create extension vector` — pgvector ist
|
||||||
`must be owner of table codings`. Im Dev-Container:
|
nicht als `trusted` gekennzeichnet) und `p2-003` (Besitzerwechsel). Im
|
||||||
|
Dev-Container über den lokalen Socket:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
podman exec -i postgres psql -U postgres -d wurzelwerk -v ON_ERROR_STOP=1 -f - < migrations/p2-001-codings-mehrschichtig.sql
|
podman exec -i postgres psql -U postgres -d wurzelwerk -v ON_ERROR_STOP=1 -f - < migrations/p2-003-eigentuemer.sql
|
||||||
```
|
```
|
||||||
|
|
||||||
Dass die Anwendungsrolle ihr eigenes Schema nicht besitzt, ist eine offene
|
Alle übrigen laufen über `DATABASE_URL`. Seit `p2-003` gehören die Tabellen
|
||||||
Frage — entweder bleibt es so und Migrationen laufen als Administrator, oder
|
der Rolle `wurzelwerk` statt `postgres` — vorher scheiterte jedes `alter table`
|
||||||
die Tabellen wechseln per `alter table … owner to wurzelwerk` den Besitzer.
|
mit `must be owner of table codings`, und die Datenbank war damit der einzige
|
||||||
|
Ausreißer auf der Instanz: `gitea` und `hedgedoc` besitzen ihre Tabellen
|
||||||
|
vollständig. Die Erweiterungen selbst (`pg_trgm`, `vector`) bleiben beim
|
||||||
|
Superuser, wo sie hingehören.
|
||||||
|
|
||||||
## Voraussetzungen an die Datenbank
|
## Voraussetzungen an die Datenbank
|
||||||
|
|
||||||
`pgvector` fehlt im Image `postgres:latest`: `select * from pg_available_extensions
|
Das Embedding-Modul braucht `pgvector`. Im offiziellen Image `postgres:latest`
|
||||||
where name = 'vector'` bleibt leer, `create extension vector` scheitert. Ohne
|
ist die Erweiterung nicht enthalten; der Postgres-Container läuft deshalb auf
|
||||||
sie kein `item_embeddings`. Das ist ein Image-Tausch auf `pgvector/pgvector:pg18`,
|
`docker.io/pgvector/pgvector:pg18-trixie` — derselbe Build wie das offizielle
|
||||||
keine Migration. Die vier übrigen Module laufen ohne.
|
Image (`18.6-1.pgdg13+2`, Debian 13, glibc 2.41), nur mit pgvector zusätzlich.
|
||||||
|
|
||||||
|
Der Tag ist mit Bedacht gewählt. Auf derselben Instanz liegen weitere Dienste;
|
||||||
|
ein Wechsel der Hauptversion oder der Distribution wäre ein Ausfall, ein
|
||||||
|
Wechsel der glibc ein Collation-Problem in den Textindizes aller Datenbanken
|
||||||
|
(`datlocprovider = 'c'`). `pg18-trixie` schließt beides aus und zieht nur
|
||||||
|
Minor-Updates nach. Die versionierten Tags (`0.8.4-pg18-trixie`) taugen dafür
|
||||||
|
nicht: sie hängen der Postgres-Minor-Version hinterher und hätten 18.6 auf
|
||||||
|
18.4 zurückgedreht.
|
||||||
|
|
||||||
|
Das Anlegen der Erweiterung in `wurzelwerk` ist davon getrennt und geschieht
|
||||||
|
in `p2-002`, nicht beim Containerstart.
|
||||||
|
|
||||||
|
Ein Vektor belegt 4100 Byte. Bei den derzeit rund 1400 Items pro Tag sind das
|
||||||
|
etwa 2 GB im Jahr, mit HNSW-Index eher das Doppelte — tragbar. Die in der
|
||||||
|
Spezifikation angenommenen 200–400 Items je Slot wären rund 38.000 am Tag und
|
||||||
|
damit etwa 55 GB im Jahr, **je `coder_version`**. Ein Modellwechsel verdoppelt
|
||||||
|
den Bedarf, bis der alte Bestand gelöscht ist. Sollte es eng werden, halbiert
|
||||||
|
`halfvec(1024)` den Platz.
|
||||||
|
|
||||||
## Grenzen des Datensatzes
|
## Grenzen des Datensatzes
|
||||||
|
|
||||||
@@ -70,3 +96,82 @@ heise 174/196, aber derstandard, welt, dlf, taz, sz jeweils 0). Das ist keine
|
|||||||
Lücke, die sich schließen lässt, sondern eine Eigenschaft der Quellen. Module
|
Lücke, die sich schließen lässt, sondern eine Eigenschaft der Quellen. Module
|
||||||
müssen auf Titel und Teaser allein tragfähig bleiben und dürfen die
|
müssen auf Titel und Teaser allein tragfähig bleiben und dürfen die
|
||||||
Vergleichsbasis nicht davon abhängig machen, ob ein Item zufällig Volltext hat.
|
Vergleichsbasis nicht davon abhängig machen, ob ein Item zufällig Volltext hat.
|
||||||
|
|
||||||
|
## Modul 1: Dubletten
|
||||||
|
|
||||||
|
```sh
|
||||||
|
.venv/bin/python dubletten.py --backfill # Gesamtbestand
|
||||||
|
.venv/bin/python dubletten.py --slot 2026-09-07T08:00 # ein Poll-Slot
|
||||||
|
.venv/bin/python dubletten.py --backfill --trocken --stichprobe 200
|
||||||
|
.venv/bin/python dubletten.py --backfill --basis titel+teaser # die andere Basis
|
||||||
|
.venv/bin/python -m unittest test_dubletten
|
||||||
|
```
|
||||||
|
|
||||||
|
Normalisierung, SimHash über Wort-Trigramme, Kandidaten über gemeinsame
|
||||||
|
SimHash-Bänder, Bestätigung über Jaccard, Union-Find. Jedes verarbeitete Item
|
||||||
|
bekommt eine Zeile, auch ein Einzelstück — sonst ließe sich „keine Dublette"
|
||||||
|
nicht von „nicht verarbeitet" unterscheiden. Unveränderte Zeilen werden nicht
|
||||||
|
angefasst, `kodiert_am` bleibt also stehen, wenn sich nichts geändert hat.
|
||||||
|
|
||||||
|
Gesamtbestand (3407 Items) 0,2 s, ein Slot 0,4 s. Das Zeitbudget von 3 Minuten
|
||||||
|
je Slot ist für dieses Modul kein Thema.
|
||||||
|
|
||||||
|
**Ergebnis auf dem Bestand vom 4.–7.9.2026:** 135 Gruppen mit 290 Mitgliedern,
|
||||||
|
davon 63 Gruppen mit 143 Items über mehrere Häuser hinweg — das ist der
|
||||||
|
eigentliche Zweck. Größte Gruppe: fünf Häuser mit derselben Zeile über den
|
||||||
|
Start der Isar-Aerospace-Rakete.
|
||||||
|
|
||||||
|
### Vier Abweichungen von der Spezifikation
|
||||||
|
|
||||||
|
**Vergleichsbasis ist der Titel, nicht Titel + Teaser.** Gemessen wurde beides.
|
||||||
|
Über Titel + Teaser findet das Verfahren auf diesem Bestand *keine einzige*
|
||||||
|
hausübergreifende Übernahme; über den Titel allein 63 Gruppen. Grund: die
|
||||||
|
Häuser übernehmen die Agenturüberschrift wörtlich und schreiben den Teaser
|
||||||
|
selbst, der Teaser verdünnt also genau das Signal, das trägt. Die Zahlen
|
||||||
|
stehen in `NOTES.md`. `--basis titel+teaser` schaltet auf die alte Basis
|
||||||
|
zurück; sie ergibt eine eigene `coder_version` und überschreibt nichts.
|
||||||
|
|
||||||
|
**Das Ressortkürzel vor dem Titel wird abgetrennt.** `Raumfahrt: Deutsche
|
||||||
|
Rakete …` und `Deutsche Rakete …` sind dieselbe Meldung, `Notfälle:` und
|
||||||
|
`Flugverkehr:` stehen vor derselben dpa-Zeile. Die Regel ist eng gefasst:
|
||||||
|
höchstens vier Wörter vor dem Doppelpunkt, mindestens fünf danach — sonst
|
||||||
|
verlöre `Habeck: Wir haben uns geirrt` seine Aussage.
|
||||||
|
|
||||||
|
**Die Gruppen-UUID kommt vom Leitartikel, nicht aus der Mitgliedermenge.**
|
||||||
|
Sonst änderte ein später eintreffendes Mitglied die Kennung einer Gruppe, die
|
||||||
|
Phase 3 bereits kodiert hat. Der Leitartikel ist das älteste Mitglied; ein
|
||||||
|
neues Mitglied ist zwangsläufig jünger und verschiebt das Minimum nicht.
|
||||||
|
|
||||||
|
**Dasselbe Haus an verschiedenen Tagen wird nicht gepaart.** Wiederkehrende
|
||||||
|
Formate — „tagesschau in 100 Sekunden", „Wetter", „Die Nachrichten" — sind
|
||||||
|
zeichengleich, aber jeweils ein eigener Vorgang. Ohne diese Regel wuchsen sie
|
||||||
|
transitiv zu einer Gruppe aus 19 Sendungen über drei Tage zusammen.
|
||||||
|
|
||||||
|
### Warum ein Slot-Lauf seinen Rand wachsen lässt
|
||||||
|
|
||||||
|
Die Items eines Slots brauchen ihre Partner im Datumsfenster, diese Partner
|
||||||
|
ihrerseits ihr eigenes Fenster. Ein fester Rand genügt trotzdem nicht:
|
||||||
|
Übernahmen bilden Ketten — dieselbe Schlagzeile erscheint am Montag bei einem,
|
||||||
|
am Dienstag beim zweiten, am Mittwoch beim dritten Haus. Wird eine solche
|
||||||
|
Kette am geladenen Rand abgeschnitten, fällt die Gruppe anders aus als im
|
||||||
|
Backfill.
|
||||||
|
|
||||||
|
Der Lauf lädt deshalb, gruppiert, und lädt erneut, solange eine Gruppe bis an
|
||||||
|
den Rand reicht. Geschrieben wird nur der innere Bereich, in dem jedes Item
|
||||||
|
seine vollständige Nachbarschaft gesehen hat.
|
||||||
|
|
||||||
|
Nachgewiesen: nach einem Backfill ändern beliebig viele Slot-Läufe nichts mehr
|
||||||
|
(`neu 0, geändert 0`), und ein anschließender Backfill ebenso wenig. Live-Lauf
|
||||||
|
und Neuaufbau liefern dasselbe Ergebnis — Invariante 2 und 4.
|
||||||
|
|
||||||
|
### Was bleibt
|
||||||
|
|
||||||
|
Auf dem gemessenen Tag findet das Verfahren 42 von 45 hausübergreifenden
|
||||||
|
Paaren. Die drei fehlenden gehen am SimHash-Bandvorfilter verloren, nicht an
|
||||||
|
den Schwellen — bei sechs bis acht Trigrammen je Titel ist SimHash grob. Ein
|
||||||
|
Trigramm-Index als zweiter Kandidatenweg würde sie einfangen; das ist nicht
|
||||||
|
gebaut, weil die Spezifikation Präzision über Recall stellt.
|
||||||
|
|
||||||
|
Die Stichprobe (`--stichprobe 200`) schreibt die Paare zum Prüfen von Hand
|
||||||
|
heraus, die zweifelhaftesten zuerst. Sie enthält Titel im Klartext und ist
|
||||||
|
deshalb nicht eingecheckt.
|
||||||
|
|||||||
+12
-3
@@ -22,9 +22,18 @@ def main():
|
|||||||
|
|
||||||
erw = [r[0] for r in conn.execute("select extname from pg_extension order by 1")]
|
erw = [r[0] for r in conn.execute("select extname from pg_extension order by 1")]
|
||||||
print(f" Extensions: {', '.join(erw)}")
|
print(f" Extensions: {', '.join(erw)}")
|
||||||
for fehlend in ("vector",):
|
|
||||||
if fehlend not in erw:
|
# Zwischen "im Image nicht vorhanden" und "nur noch nicht angelegt"
|
||||||
print(f" fehlt noch fuer Phase 2: {fehlend}")
|
# unterscheiden - das erste ist ein Image-Tausch, das zweite eine
|
||||||
|
# Migration.
|
||||||
|
if "vector" not in erw:
|
||||||
|
verfuegbar = conn.execute(
|
||||||
|
"select default_version from pg_available_extensions where name = 'vector'"
|
||||||
|
).fetchone()
|
||||||
|
if verfuegbar:
|
||||||
|
print(f" vector {verfuegbar[0]} liegt bereit, ist aber nicht angelegt")
|
||||||
|
else:
|
||||||
|
print(" vector fehlt im Image - Phase 2 kann keine Embeddings ablegen")
|
||||||
|
|
||||||
print("\nTabellen")
|
print("\nTabellen")
|
||||||
for name, in conn.execute(
|
for name, in conn.execute(
|
||||||
|
|||||||
@@ -11,12 +11,12 @@ import pathlib
|
|||||||
_ENV = pathlib.Path(__file__).with_name(".env")
|
_ENV = pathlib.Path(__file__).with_name(".env")
|
||||||
|
|
||||||
|
|
||||||
def _aus_env_datei(pfad=_ENV):
|
def _aus_env_datei():
|
||||||
"""Minimaler .env-Leser. Kein Shell-Parsing, keine Ersetzungen."""
|
"""Minimaler .env-Leser. Kein Shell-Parsing, keine Ersetzungen."""
|
||||||
werte = {}
|
werte = {}
|
||||||
if not pfad.exists():
|
if not _ENV.exists():
|
||||||
return werte
|
return werte
|
||||||
for zeile in pfad.read_text(encoding="utf-8").splitlines():
|
for zeile in _ENV.read_text(encoding="utf-8").splitlines():
|
||||||
zeile = zeile.strip()
|
zeile = zeile.strip()
|
||||||
if not zeile or zeile.startswith("#") or "=" not in zeile:
|
if not zeile or zeile.startswith("#") or "=" not in zeile:
|
||||||
continue
|
continue
|
||||||
|
|||||||
Executable
+659
@@ -0,0 +1,659 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Phase 2, Modul 1: exakte Uebernahmen (ebene = 'dublette').
|
||||||
|
|
||||||
|
Agenturmeldungen erscheinen bei mehreren Haeusern nahezu gleichlautend.
|
||||||
|
Dieses Modul fasst sie zusammen, damit Phase 3 nur den Leitartikel kodiert.
|
||||||
|
|
||||||
|
Kein thematisches Clustering. Nur near-exact: erkannt wird, dass zwei Haeuser
|
||||||
|
denselben Text ausgespielt haben, nicht dass sie ueber dasselbe schreiben.
|
||||||
|
|
||||||
|
python3 dubletten.py --backfill # Gesamtbestand
|
||||||
|
python3 dubletten.py --slot 2026-09-07T08:00
|
||||||
|
python3 dubletten.py --backfill --trocken --stichprobe 200
|
||||||
|
|
||||||
|
Verfahren nach Abschnitt 3 der Spezifikation: Normalisierung, SimHash ueber
|
||||||
|
Wort-Trigramme, Kandidaten ueber Hamming-Distanz, Bestaetigung ueber Jaccard,
|
||||||
|
Union-Find.
|
||||||
|
|
||||||
|
Wo das Verfahren von der Spezifikation abweicht, steht die Begruendung an
|
||||||
|
Ort und Stelle: BASIS_VORGABE, gruppen_id(), die Paarregel in paare() und
|
||||||
|
_zerlege_ketten(). README.md fasst sie zusammen, NOTES.md haelt die Messungen
|
||||||
|
fest, auf denen sie beruhen.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import datetime as dt
|
||||||
|
import hashlib
|
||||||
|
import itertools
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from psycopg.types.json import Jsonb
|
||||||
|
|
||||||
|
import normalisierung
|
||||||
|
import version as versionsmodul
|
||||||
|
from db import verbindung
|
||||||
|
|
||||||
|
EBENE = "dublette"
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# Vergleichsbasis: "titel" oder "titel+teaser".
|
||||||
|
#
|
||||||
|
# Die Spezifikation sagt "Titel + Teaser, oder Volltext falls vorhanden".
|
||||||
|
# Beides taugt hier nicht.
|
||||||
|
#
|
||||||
|
# Volltext nicht, weil ihn nur ein Teil der Quellen liefert - Ingest liest
|
||||||
|
# RSS, und viele Haeuser spielen dort kein content:encoded aus (Stand
|
||||||
|
# 7.9.2026: faz 311/489, heise 174/196, aber derstandard, welt, dlf, taz, sz
|
||||||
|
# jeweils 0). Eine je Item verschiedene Basis macht ausgerechnet den
|
||||||
|
# haeufigsten Fall unauffindbar: dieselbe Meldung bei einem Haus mit und bei
|
||||||
|
# einem ohne Volltext.
|
||||||
|
#
|
||||||
|
# Titel + Teaser nicht, weil die Haeuser die Agenturueberschrift woertlich
|
||||||
|
# uebernehmen, den Teaser aber selbst schreiben. Gemessen wurde beides: ueber
|
||||||
|
# Titel + Teaser findet das Verfahren auf diesem Bestand keine einzige
|
||||||
|
# hausuebergreifende Uebernahme, ueber den Titel allein 63 Gruppen. Der
|
||||||
|
# Teaser verduennt genau das Signal, das traegt. Zahlen in NOTES.md.
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
BASIS_VORGABE = "titel"
|
||||||
|
|
||||||
|
# Mindestwortzahl je Basis. Wenige Trigramme erreichen leicht einen hohen
|
||||||
|
# Jaccard-Wert, ohne dass die Meldungen etwas miteinander zu tun haetten -
|
||||||
|
# lieber eine Dublette verpassen als zwei Vorgaenge verschmelzen
|
||||||
|
# (Abschnitt 3, "Erwartung"). Titel sind kuerzer, die Schwelle entsprechend.
|
||||||
|
MINDESTWOERTER = {"titel": 5, "titel+teaser": 8}
|
||||||
|
|
||||||
|
# Reissleine fuer die Randerweiterung beim Slot-Lauf.
|
||||||
|
RANDERWEITERUNGEN = 6
|
||||||
|
|
||||||
|
EINSTELLUNGEN = {
|
||||||
|
"basis": BASIS_VORGABE,
|
||||||
|
"simhash_bits": 64,
|
||||||
|
"trigramm": "wort-3",
|
||||||
|
"hamming_max": 3,
|
||||||
|
"jaccard_min": 0.85,
|
||||||
|
"fenster_tage": 1,
|
||||||
|
"mindestwoerter": MINDESTWOERTER[BASIS_VORGABE],
|
||||||
|
"ressort_abgetrennt": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
_AGENTUREN = [
|
||||||
|
("dpa", re.compile(r"\((?:dpa|dpa-AFX)[^)]{0,20}\)|\bdpa\b")),
|
||||||
|
("afp", re.compile(r"\(AFP[^)]{0,20}\)|\bAFP\b")),
|
||||||
|
("reuters", re.compile(r"\bReuters\b")),
|
||||||
|
("ap", re.compile(r"\(AP\)|\bAssociated Press\b")),
|
||||||
|
("epd", re.compile(r"\bepd\b")),
|
||||||
|
("kna", re.compile(r"\bKNA\b")),
|
||||||
|
("sid", re.compile(r"\bSID\b")),
|
||||||
|
("dts", re.compile(r"\bdts Nachrichtenagentur\b")),
|
||||||
|
]
|
||||||
|
|
||||||
|
# Fester Namensraum, damit Gruppen-UUIDs ueber Laeufe und Maschinen hinweg
|
||||||
|
# gleich bleiben. Willkuerlich gewaehlt, aber unveraenderlich.
|
||||||
|
NAMENSRAUM = uuid.UUID("6f9b4d2e-1c53-4a7f-9e18-2b0d7c5a3e41")
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# SimHash
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
def _merkmalshash(trigramm):
|
||||||
|
"""64 Bit je Trigramm.
|
||||||
|
|
||||||
|
blake2b und nicht hash(): Pythons hash() fuer Zeichenketten ist je
|
||||||
|
Prozess zufaellig gesalzen. Damit waere nichts reproduzierbar.
|
||||||
|
"""
|
||||||
|
roh = "\x1f".join(trigramm).encode("utf-8")
|
||||||
|
return int.from_bytes(hashlib.blake2b(roh, digest_size=8).digest(), "big")
|
||||||
|
|
||||||
|
|
||||||
|
def simhash(trigramme):
|
||||||
|
if not trigramme:
|
||||||
|
return 0
|
||||||
|
gewichte = [0] * 64
|
||||||
|
for tri in trigramme:
|
||||||
|
h = _merkmalshash(tri)
|
||||||
|
for bit in range(64):
|
||||||
|
gewichte[bit] += 1 if (h >> bit) & 1 else -1
|
||||||
|
wert = 0
|
||||||
|
for bit in range(64):
|
||||||
|
# Gleichstand faellt auf 0. Willkuerlich, aber deterministisch.
|
||||||
|
if gewichte[bit] > 0:
|
||||||
|
wert |= 1 << bit
|
||||||
|
return wert
|
||||||
|
|
||||||
|
|
||||||
|
def hamming(a, b):
|
||||||
|
return (a ^ b).bit_count()
|
||||||
|
|
||||||
|
|
||||||
|
BAENDER = 4
|
||||||
|
|
||||||
|
|
||||||
|
def baender(wert):
|
||||||
|
"""SimHash in vier Stuecke zu 16 Bit zerlegen.
|
||||||
|
|
||||||
|
Schubfachschluss: unterscheiden sich zwei Werte in hoechstens 3 Bits, so
|
||||||
|
ist bei vier Baendern mindestens eines gleich. Nur Paare, die sich ein
|
||||||
|
Band teilen, muessen ueberhaupt verglichen werden - das ersetzt den
|
||||||
|
Vergleich jeder Meldung mit jeder. Die Zahl der Baender haengt an
|
||||||
|
hamming_max und darf nicht ohne dieses geaendert werden.
|
||||||
|
"""
|
||||||
|
breite = 64 // BAENDER
|
||||||
|
maske = (1 << breite) - 1
|
||||||
|
return tuple((i, (wert >> (i * breite)) & maske) for i in range(BAENDER))
|
||||||
|
|
||||||
|
|
||||||
|
def jaccard(a, b):
|
||||||
|
if not a or not b:
|
||||||
|
return 0.0
|
||||||
|
schnitt = len(a & b)
|
||||||
|
return schnitt / (len(a) + len(b) - schnitt)
|
||||||
|
|
||||||
|
|
||||||
|
class UnionFind:
|
||||||
|
def __init__(self):
|
||||||
|
self.eltern = {}
|
||||||
|
|
||||||
|
def finde(self, x):
|
||||||
|
self.eltern.setdefault(x, x)
|
||||||
|
while self.eltern[x] != x:
|
||||||
|
self.eltern[x] = self.eltern[self.eltern[x]]
|
||||||
|
x = self.eltern[x]
|
||||||
|
return x
|
||||||
|
|
||||||
|
def vereinige(self, a, b):
|
||||||
|
wa, wb = self.finde(a), self.finde(b)
|
||||||
|
if wa != wb:
|
||||||
|
# Kleinere Wurzel gewinnt: macht das Ergebnis unabhaengig von der
|
||||||
|
# Reihenfolge, in der die Paare hereinkommen.
|
||||||
|
hoch, tief = max(wa, wb), min(wa, wb)
|
||||||
|
self.eltern[hoch] = tief
|
||||||
|
|
||||||
|
|
||||||
|
def agentur(*texte):
|
||||||
|
"""Agentur nur mit Textbeleg. Keine Vermutung ins Blaue."""
|
||||||
|
text = " ".join(t for t in texte if t)
|
||||||
|
for name, muster in _AGENTUREN:
|
||||||
|
if muster.search(text):
|
||||||
|
return name
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def gruppen_id(leitartikel_id):
|
||||||
|
"""UUID aus dem Leitartikel, nicht aus der Mitgliedermenge.
|
||||||
|
|
||||||
|
Die Spezifikation leitet sie aus der sortierten Menge der raw_item_id ab.
|
||||||
|
Das haelt nicht: erscheint dieselbe Meldung drei Stunden spaeter bei einem
|
||||||
|
weiteren Haus, aendert sich die Menge und damit die UUID - eine Gruppe,
|
||||||
|
die Phase 3 bereits kodiert hat, hiesse ploetzlich anders.
|
||||||
|
|
||||||
|
Der Leitartikel ist das aeltestgesehene Mitglied. Kommt ein Mitglied
|
||||||
|
hinzu, ist es zwangslaeufig juenger und aendert das Minimum nicht. Die
|
||||||
|
Kennung bleibt damit stabil, solange die Gruppe waechst. Sie wechselt
|
||||||
|
nur, wenn zwei bestehende Gruppen verschmelzen - dann ist es richtig,
|
||||||
|
dass sich etwas aendert.
|
||||||
|
"""
|
||||||
|
return str(uuid.uuid5(NAMENSRAUM, f"dublette:{leitartikel_id}"))
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# Laden
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
class Item:
|
||||||
|
__slots__ = ("id", "quelle", "titel", "teaser", "gesehen", "datum",
|
||||||
|
"inhalt_hash", "basis", "text", "trigramme", "simhash", "woerter")
|
||||||
|
|
||||||
|
def __init__(self, id, quelle, titel, teaser, gesehen, datum, inhalt_hash,
|
||||||
|
basis=None):
|
||||||
|
self.id = id
|
||||||
|
self.quelle = quelle
|
||||||
|
self.titel = titel
|
||||||
|
self.teaser = teaser
|
||||||
|
self.gesehen = gesehen
|
||||||
|
self.datum = datum
|
||||||
|
self.inhalt_hash = inhalt_hash
|
||||||
|
self.basis = basis or BASIS_VORGABE
|
||||||
|
if self.basis == "titel":
|
||||||
|
# Ressortkuerzel abtrennen: dieselbe Agenturzeile bekommt bei
|
||||||
|
# jedem Haus ein anderes vorangestellt, oder gar keins.
|
||||||
|
self.text = normalisierung.normalisiere(titel, ressort_abtrennen=True)
|
||||||
|
else:
|
||||||
|
self.text = normalisierung.normalisiere(titel, teaser)
|
||||||
|
self.woerter = len(normalisierung.woerter(self.text))
|
||||||
|
self.trigramme = normalisierung.trigramme(self.text)
|
||||||
|
self.simhash = simhash(self.trigramme)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def vergleichbar(self):
|
||||||
|
return self.woerter >= MINDESTWOERTER[self.basis]
|
||||||
|
|
||||||
|
|
||||||
|
_SPALTEN = """id, quelle, titel, teaser, abgerufen_am,
|
||||||
|
coalesce(pubdate, abgerufen_am)::date as datum, inhalt_hash"""
|
||||||
|
|
||||||
|
|
||||||
|
def lade_bereich(conn, basis, von=None, bis=None):
|
||||||
|
"""Items eines Datumsbereichs, ohne Grenzen der Gesamtbestand.
|
||||||
|
|
||||||
|
Das Datum kommt aus pubdate mit Rueckfall auf abgerufen_am: Phase 1
|
||||||
|
vermerkt ausdruecklich, dass pubdate fehlen oder luegen kann.
|
||||||
|
"""
|
||||||
|
if von is None:
|
||||||
|
zeilen = conn.execute(f"select {_SPALTEN} from raw_items order by id")
|
||||||
|
else:
|
||||||
|
zeilen = conn.execute(
|
||||||
|
f"""select {_SPALTEN} from raw_items
|
||||||
|
where coalesce(pubdate, abgerufen_am)::date between %s and %s
|
||||||
|
order by id""", (von, bis))
|
||||||
|
return [Item(*z, basis=basis) for z in zeilen]
|
||||||
|
|
||||||
|
|
||||||
|
def _beruehrt_rand(ergebnis, lade_von, lade_bis):
|
||||||
|
"""Reicht eine Gruppe bis an den Rand des geladenen Bereichs?
|
||||||
|
|
||||||
|
Dann kann jenseits davon noch ein Mitglied liegen, und die Gruppe ist
|
||||||
|
unvollstaendig gerechnet.
|
||||||
|
"""
|
||||||
|
for gruppe in ergebnis.values():
|
||||||
|
if len(gruppe["mitglieder"]) < 2:
|
||||||
|
continue
|
||||||
|
for m in gruppe["mitglieder"]:
|
||||||
|
if m.datum <= lade_von or m.datum >= lade_bis:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def lade_und_gruppiere(conn, slot=None, fenster_tage=None, basis=None, **grenzen):
|
||||||
|
"""Liefert (items, uf, ergebnis, schreibbereich).
|
||||||
|
|
||||||
|
Laden und Gruppieren haengen beim Slot-Lauf zusammen und sind darum eine
|
||||||
|
Einheit: wieviel geladen werden muss, zeigt sich erst an den Gruppen.
|
||||||
|
|
||||||
|
Ohne slot der Gesamtbestand, dann ist alles beschreibbar.
|
||||||
|
|
||||||
|
Mit slot wird ein Rand mitgeladen und nur der innere Bereich beschrieben -
|
||||||
|
dort, wo jedes Item seine vollstaendige Nachbarschaft gesehen hat. Eine
|
||||||
|
unvollstaendig gerechnete Zeile darf eine vollstaendige nicht
|
||||||
|
ueberschreiben.
|
||||||
|
|
||||||
|
Ein fester Rand genuegt dabei nicht. Uebernahmen bilden Ketten: dieselbe
|
||||||
|
Schlagzeile erscheint am Montag bei einem, am Dienstag beim zweiten, am
|
||||||
|
Mittwoch beim dritten Haus. Wo eine Kette am geladenen Rand abgeschnitten
|
||||||
|
wird, faellt die Gruppe anders aus als im Backfill - und genau das darf
|
||||||
|
nicht sein (Invariante 2 und 4). Also wird geladen, gruppiert, und wenn
|
||||||
|
eine Gruppe bis an den Rand reicht, weiter geladen. In der Praxis laeuft
|
||||||
|
das ein- bis zweimal.
|
||||||
|
"""
|
||||||
|
fenster_tage = EINSTELLUNGEN["fenster_tage"] if fenster_tage is None else fenster_tage
|
||||||
|
|
||||||
|
if slot is None:
|
||||||
|
items = lade_bereich(conn, basis)
|
||||||
|
uf, ergebnis = gruppiere(items, fenster_tage=fenster_tage, **grenzen)
|
||||||
|
return items, uf, ergebnis, None
|
||||||
|
|
||||||
|
von, bis = conn.execute(
|
||||||
|
"""select min(coalesce(pubdate, abgerufen_am)::date),
|
||||||
|
max(coalesce(pubdate, abgerufen_am)::date)
|
||||||
|
from raw_items where slot = %s""", (slot,)).fetchone()
|
||||||
|
if von is None:
|
||||||
|
return [], None, None, None
|
||||||
|
|
||||||
|
fenster = dt.timedelta(days=fenster_tage)
|
||||||
|
schreibbereich = (von - fenster, bis + fenster)
|
||||||
|
|
||||||
|
rand = 2 * fenster_tage
|
||||||
|
for versuch in range(RANDERWEITERUNGEN):
|
||||||
|
lade_von = von - dt.timedelta(days=rand)
|
||||||
|
lade_bis = bis + dt.timedelta(days=rand)
|
||||||
|
items = lade_bereich(conn, basis, lade_von, lade_bis)
|
||||||
|
uf, ergebnis = gruppiere(items, fenster_tage=fenster_tage, **grenzen)
|
||||||
|
if not _beruehrt_rand(ergebnis, lade_von, lade_bis):
|
||||||
|
return items, uf, ergebnis, schreibbereich
|
||||||
|
rand += fenster_tage
|
||||||
|
|
||||||
|
# Reissleine: lieber den ganzen Bestand rechnen als etwas Falsches
|
||||||
|
# schreiben. Bisher nie erreicht.
|
||||||
|
items = lade_bereich(conn, basis)
|
||||||
|
uf, ergebnis = gruppiere(items, fenster_tage=fenster_tage, **grenzen)
|
||||||
|
return items, uf, ergebnis, schreibbereich
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# Gruppieren
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
def paare(items, fenster_tage=EINSTELLUNGEN["fenster_tage"],
|
||||||
|
hamming_max=EINSTELLUNGEN["hamming_max"],
|
||||||
|
jaccard_min=EINSTELLUNGEN["jaccard_min"]):
|
||||||
|
"""Bestaetigte Paare finden.
|
||||||
|
|
||||||
|
Erst Kandidaten ueber gemeinsame SimHash-Baender, dann die drei Pruefungen
|
||||||
|
aus der Spezifikation: Datumsnaehe, Hamming-Distanz, Jaccard-Wert.
|
||||||
|
"""
|
||||||
|
nach_band = {}
|
||||||
|
brauchbar = [i for i in items if i.vergleichbar]
|
||||||
|
for item in brauchbar:
|
||||||
|
for band in baender(item.simhash):
|
||||||
|
nach_band.setdefault(band, []).append(item)
|
||||||
|
|
||||||
|
gesehen = set()
|
||||||
|
fenster = dt.timedelta(days=fenster_tage)
|
||||||
|
for eimer in nach_band.values():
|
||||||
|
if len(eimer) < 2:
|
||||||
|
continue
|
||||||
|
for a, b in itertools.combinations(eimer, 2):
|
||||||
|
schluessel = (a.id, b.id) if a.id < b.id else (b.id, a.id)
|
||||||
|
if schluessel in gesehen:
|
||||||
|
continue
|
||||||
|
gesehen.add(schluessel)
|
||||||
|
if abs(a.datum - b.datum) > fenster:
|
||||||
|
continue
|
||||||
|
# Dasselbe Haus an verschiedenen Tagen ist keine Uebernahme,
|
||||||
|
# sondern ein wiederkehrendes Format: "tagesschau in 100
|
||||||
|
# Sekunden", "Wetter", "Die Nachrichten". Gleicher Text, anderer
|
||||||
|
# Vorgang - kein Textverfahren kann die auseinanderhalten, also
|
||||||
|
# muss die Regel es tun.
|
||||||
|
#
|
||||||
|
# Die Regel sitzt bewusst hier auf Paarebene und nicht auf der
|
||||||
|
# fertigen Gruppe: sie haengt allein an den beiden Items und
|
||||||
|
# faellt damit gleich aus, egal wieviel Bestand geladen ist.
|
||||||
|
# Eine Regel auf Gruppenebene waere fensterabhaengig - und dann
|
||||||
|
# lieferte ein Slot-Lauf andere Gruppen als ein Backfill.
|
||||||
|
if a.quelle == b.quelle and a.datum != b.datum:
|
||||||
|
continue
|
||||||
|
if hamming(a.simhash, b.simhash) > hamming_max:
|
||||||
|
continue
|
||||||
|
# Bestaetigung. Auf dem Bestand vom 4.-7.9.2026 verwirft sie
|
||||||
|
# kein einziges Paar mehr, das die Hamming-Grenze passiert hat -
|
||||||
|
# sie ist trotzdem die eigentliche Praezisionszusage. SimHash
|
||||||
|
# kann kollidieren, Jaccard nicht.
|
||||||
|
j = jaccard(a.trigramme, b.trigramme)
|
||||||
|
if j < jaccard_min:
|
||||||
|
continue
|
||||||
|
yield a, b, j
|
||||||
|
|
||||||
|
|
||||||
|
def _zerlege_ketten(gruppe, fenster_tage):
|
||||||
|
"""Gruppen aufbrechen, die nur ueber eine Kette zusammenhaengen.
|
||||||
|
|
||||||
|
Union-Find ist transitiv, das Datumsfenster ist es nicht: A passt zu B,
|
||||||
|
B zu C, und schon haengt A mit C zusammen, obwohl mehr als ein Tag
|
||||||
|
dazwischen liegt. Eine Uebernahme ist ein Vorgang, der bei mehreren
|
||||||
|
Haeusern gleichzeitig erscheint - keine Schlagzeile, die drei Tage lang
|
||||||
|
weitergereicht wird. Eine Gruppe darf darum nicht mehr Tage umspannen
|
||||||
|
als das Fenster.
|
||||||
|
|
||||||
|
Auf dem Bestand vom 4.-7.9.2026 trifft das genau eine von 135
|
||||||
|
Komponenten: dieselbe Newsblog-Zeile bei handelsblatt (4.9.), sz (5.9.)
|
||||||
|
und tagesschau (6.9.). Der Schnitt ist also kein Massenphaenomen,
|
||||||
|
sondern verhindert, dass Phase 3 den Stand vom Mittwoch mit der
|
||||||
|
Kodierung vom Montag versieht.
|
||||||
|
|
||||||
|
Getrennt wird nach Datum, damit das Ergebnis nicht von der
|
||||||
|
Ladereihenfolge abhaengt.
|
||||||
|
"""
|
||||||
|
spanne = dt.timedelta(days=fenster_tage)
|
||||||
|
geordnet = sorted(gruppe, key=lambda i: (i.datum, i.gesehen, i.id))
|
||||||
|
teile, aktuell = [], [geordnet[0]]
|
||||||
|
for item in geordnet[1:]:
|
||||||
|
if item.datum - aktuell[0].datum > spanne:
|
||||||
|
teile.append(aktuell)
|
||||||
|
aktuell = [item]
|
||||||
|
else:
|
||||||
|
aktuell.append(item)
|
||||||
|
teile.append(aktuell)
|
||||||
|
return teile
|
||||||
|
|
||||||
|
|
||||||
|
def gruppiere(items, fenster_tage=EINSTELLUNGEN["fenster_tage"], **grenzen):
|
||||||
|
"""Union-Find ueber die bestaetigten Paare.
|
||||||
|
|
||||||
|
Liefert je Item die Gruppe, ihre Mitglieder, den Leitartikel und den
|
||||||
|
kleinsten Jaccard-Wert, der die Gruppe zusammenhaelt.
|
||||||
|
"""
|
||||||
|
uf = UnionFind()
|
||||||
|
schwaechste = {}
|
||||||
|
for a, b, j in paare(items, fenster_tage=fenster_tage, **grenzen):
|
||||||
|
uf.vereinige(a.id, b.id)
|
||||||
|
schwaechste[(a.id, b.id)] = j
|
||||||
|
|
||||||
|
roh = {}
|
||||||
|
for item in items:
|
||||||
|
roh.setdefault(uf.finde(item.id), []).append(item)
|
||||||
|
|
||||||
|
# Ketten aufbrechen und die Zugehoerigkeit neu festschreiben. Danach ist
|
||||||
|
# uf.finde() wieder die Wahrheit ueber die Gruppen.
|
||||||
|
mitglieder = {}
|
||||||
|
for gruppe in roh.values():
|
||||||
|
for teil in _zerlege_ketten(gruppe, fenster_tage):
|
||||||
|
wurzel = min(i.id for i in teil)
|
||||||
|
for item in teil:
|
||||||
|
uf.eltern[item.id] = wurzel
|
||||||
|
mitglieder[wurzel] = teil
|
||||||
|
|
||||||
|
ergebnis = {}
|
||||||
|
for wurzel, gruppe in mitglieder.items():
|
||||||
|
# Leitartikel: aeltestes gesehenes Item, bei Gleichstand die kleinste
|
||||||
|
# id. Rein deterministisch, unabhaengig von der Ladereihenfolge.
|
||||||
|
leit = min(gruppe, key=lambda i: (i.gesehen, i.id))
|
||||||
|
ids = {i.id for i in gruppe}
|
||||||
|
werte = [j for (x, y), j in schwaechste.items() if x in ids and y in ids]
|
||||||
|
ergebnis[wurzel] = {
|
||||||
|
"mitglieder": gruppe,
|
||||||
|
"leitartikel": leit,
|
||||||
|
"jaccard_min": round(min(werte), 4) if werte else None,
|
||||||
|
}
|
||||||
|
return uf, ergebnis
|
||||||
|
|
||||||
|
|
||||||
|
def nutzlast(item, gruppe):
|
||||||
|
leit = gruppe["leitartikel"]
|
||||||
|
groesse = len(gruppe["mitglieder"])
|
||||||
|
return {
|
||||||
|
"gruppe": gruppen_id(leit.id),
|
||||||
|
"gruppengroesse": groesse,
|
||||||
|
"leitartikel": leit.id,
|
||||||
|
"simhash": f"{item.simhash:016x}",
|
||||||
|
"jaccard_min": gruppe["jaccard_min"],
|
||||||
|
"agentur_vermutet": agentur(item.titel, item.teaser),
|
||||||
|
# Basis und Stand des Texts, gegen den kodiert wurde. Ohne das laesst
|
||||||
|
# sich spaeter nicht sagen, ob eine Kodierung noch zum Item passt:
|
||||||
|
# raw_items haelt den *aktuellen* Titel, Redaktionen aendern ihn nach
|
||||||
|
# (in diesem Bestand 3 von 10 Items), und dann ist der Vergleich von
|
||||||
|
# gestern gegen einen Text gelaufen, den es so nicht mehr gibt.
|
||||||
|
"basis": item.basis,
|
||||||
|
"basis_hash": item.inhalt_hash,
|
||||||
|
"woerter": item.woerter,
|
||||||
|
"vergleichbar": item.vergleichbar,
|
||||||
|
# Eine Gruppe aus einem einzigen Haus ist keine Uebernahme, sondern
|
||||||
|
# eine Wiederholung im selben Feed. Fuer Phase 3 ist der Unterschied
|
||||||
|
# wesentlich: bei einer Uebernahme erben die Mitglieder die Kodierung
|
||||||
|
# des Leitartikels, eine Wiederholung ist derselbe Vorgang zweimal.
|
||||||
|
"haeuser": len({i.quelle for i in gruppe["mitglieder"]}),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# Schreiben
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
def schreibe(conn, items, ergebnis, uf, coder_version, trocken=False, schreibbereich=None):
|
||||||
|
"""Codings ablegen, aber nur wo sich etwas geaendert hat.
|
||||||
|
|
||||||
|
Jedes verarbeitete Item bekommt eine Zeile, auch ein Einzelstueck: sonst
|
||||||
|
liesse sich "keine Dublette" nicht von "nicht verarbeitet" unterscheiden.
|
||||||
|
|
||||||
|
Unveraenderte Zeilen werden nicht angefasst. Ein Slot-Lauf rechnet ueber
|
||||||
|
das ganze Datumsfenster, und ohne diesen Vergleich schriebe er bei jedem
|
||||||
|
Durchgang einige tausend Zeilen neu - mit neuem kodiert_am, ohne dass
|
||||||
|
sich etwas geaendert haette.
|
||||||
|
"""
|
||||||
|
vorhanden = {
|
||||||
|
r[0]: r[1]
|
||||||
|
for r in conn.execute(
|
||||||
|
"select raw_item_id, nutzlast from codings"
|
||||||
|
" where coder_version = %s and ebene = %s", (coder_version, EBENE))
|
||||||
|
}
|
||||||
|
|
||||||
|
if schreibbereich is None:
|
||||||
|
zu_schreiben = items
|
||||||
|
else:
|
||||||
|
von, bis = schreibbereich
|
||||||
|
zu_schreiben = [i for i in items if von <= i.datum <= bis]
|
||||||
|
|
||||||
|
neu = geaendert = unveraendert = 0
|
||||||
|
stapel = []
|
||||||
|
for item in zu_schreiben:
|
||||||
|
gruppe = ergebnis[uf.finde(item.id)]
|
||||||
|
last = nutzlast(item, gruppe)
|
||||||
|
alt = vorhanden.get(item.id)
|
||||||
|
if alt == last:
|
||||||
|
unveraendert += 1
|
||||||
|
continue
|
||||||
|
if alt is None:
|
||||||
|
neu += 1
|
||||||
|
else:
|
||||||
|
geaendert += 1
|
||||||
|
stapel.append((item.id, coder_version, EBENE, last["jaccard_min"], Jsonb(last)))
|
||||||
|
|
||||||
|
if stapel and not trocken:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.executemany(
|
||||||
|
"""insert into codings (raw_item_id, coder_version, ebene, konfidenz, nutzlast)
|
||||||
|
values (%s, %s, %s, %s, %s)
|
||||||
|
on conflict (raw_item_id, coder_version, ebene)
|
||||||
|
do update set nutzlast = excluded.nutzlast,
|
||||||
|
konfidenz = excluded.konfidenz,
|
||||||
|
kodiert_am = now()""",
|
||||||
|
stapel)
|
||||||
|
return {"neu": neu, "geaendert": geaendert, "unveraendert": unveraendert,
|
||||||
|
"ausserhalb": len(items) - len(zu_schreiben)}
|
||||||
|
|
||||||
|
|
||||||
|
def protokolliere(conn, slot, coder_version, dauer_ms, gesehen, kodiert, fehler=None):
|
||||||
|
conn.execute(
|
||||||
|
"""insert into p2_laeufe (slot, modul, coder_version, dauer_ms,
|
||||||
|
items_gesehen, items_kodiert, items_fehler, fehler)
|
||||||
|
values (%s, %s, %s, %s, %s, %s, %s, %s)""",
|
||||||
|
(slot, EBENE, coder_version, dauer_ms, gesehen, kodiert, 0 if not fehler else 1, fehler))
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# Bericht und Stichprobe
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
def bericht(items, ergebnis):
|
||||||
|
gruppen = [g for g in ergebnis.values() if len(g["mitglieder"]) > 1]
|
||||||
|
in_gruppen = sum(len(g["mitglieder"]) for g in gruppen)
|
||||||
|
ueber = [g for g in gruppen if len({i.quelle for i in g["mitglieder"]}) > 1]
|
||||||
|
kurz = sum(1 for i in items if not i.vergleichbar)
|
||||||
|
mindest = MINDESTWOERTER[items[0].basis] if items else 0
|
||||||
|
print(f" Items: {len(items)}")
|
||||||
|
print(f" zu kurz: {kurz} (< {mindest} Woerter, nicht verglichen)")
|
||||||
|
print(f" Uebernahmegruppen:{len(gruppen):>4} mit {in_gruppen} Mitgliedern")
|
||||||
|
print(f" davon ueber mehrere Haeuser: {len(ueber)} <- der eigentliche Zweck")
|
||||||
|
if gruppen:
|
||||||
|
groessen = sorted((len(g["mitglieder"]) for g in gruppen), reverse=True)
|
||||||
|
print(f" groesste Gruppe: {groessen[0]}")
|
||||||
|
for g in sorted(gruppen, key=lambda g: -len(g["mitglieder"]))[:5]:
|
||||||
|
haeuser = sorted({i.quelle for i in g["mitglieder"]})
|
||||||
|
leit = g["leitartikel"]
|
||||||
|
print(f" [{len(g['mitglieder'])}] j={g['jaccard_min']} {', '.join(haeuser)}")
|
||||||
|
print(f" {leit.titel[:88]}")
|
||||||
|
|
||||||
|
|
||||||
|
def stichprobe(ergebnis, anzahl, pfad):
|
||||||
|
"""Paare zum Nachprüfen von Hand ausschreiben.
|
||||||
|
|
||||||
|
Die Spezifikation verlangt Praezision >= 0.98 auf 200 handgeprueften
|
||||||
|
Paaren. Pruefen kann das nur ein Mensch; diese Datei ist die Vorlage
|
||||||
|
dafuer. Sie enthaelt Titel im Klartext und gehoert deshalb nicht in die
|
||||||
|
Datenbank, sondern neben sie.
|
||||||
|
"""
|
||||||
|
zeilen = []
|
||||||
|
for gruppe in ergebnis.values():
|
||||||
|
m = gruppe["mitglieder"]
|
||||||
|
if len(m) < 2:
|
||||||
|
continue
|
||||||
|
for a, b in itertools.combinations(sorted(m, key=lambda i: i.id), 2):
|
||||||
|
zeilen.append((jaccard(a.trigramme, b.trigramme),
|
||||||
|
hamming(a.simhash, b.simhash), a, b))
|
||||||
|
zeilen.sort(key=lambda z: (z[0], z[3].id)) # schwaechste zuerst - dort sitzen die Fehler
|
||||||
|
with open(pfad, "w", encoding="utf-8") as f:
|
||||||
|
f.write("# Dubletten-Stichprobe. Spalte 'urteil' von Hand fuellen: j = Dublette, n = keine.\n")
|
||||||
|
f.write("# Absteigend nach Zweifel sortiert: die obersten Paare sind die knappsten.\n\n")
|
||||||
|
for j, h, a, b in zeilen[:anzahl]:
|
||||||
|
f.write(f"urteil=_ jaccard={j:.3f} hamming={h}\n")
|
||||||
|
f.write(f" {a.id:>7} {a.quelle:<14}{a.titel}\n")
|
||||||
|
f.write(f" {b.id:>7} {b.quelle:<14}{b.titel}\n\n")
|
||||||
|
return min(len(zeilen), anzahl), len(zeilen)
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv=None):
|
||||||
|
p = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||||
|
p.add_argument("--slot", help="ein Slot, z.B. 2026-09-07T08:00")
|
||||||
|
p.add_argument("--backfill", action="store_true", help="Gesamtbestand")
|
||||||
|
p.add_argument("--basis", choices=("titel", "titel+teaser"), default=BASIS_VORGABE,
|
||||||
|
help=f"Vergleichsbasis (Vorgabe {BASIS_VORGABE})")
|
||||||
|
p.add_argument("--jaccard", type=float, default=EINSTELLUNGEN["jaccard_min"],
|
||||||
|
help="Bestaetigungsschwelle (Vorgabe %(default)s)")
|
||||||
|
p.add_argument("--hamming", type=int, default=EINSTELLUNGEN["hamming_max"],
|
||||||
|
help="hoechste Hamming-Distanz fuer Kandidaten (Vorgabe %(default)s)")
|
||||||
|
p.add_argument("--fenster", type=int, default=1, help="Datumsfenster in Tagen (Vorgabe 1)")
|
||||||
|
p.add_argument("--trocken", action="store_true", help="rechnen, nichts schreiben")
|
||||||
|
p.add_argument("--stichprobe", type=int, metavar="N", help="N Paare zum Nachpruefen ausschreiben")
|
||||||
|
p.add_argument("--stichprobe-datei", default="stichprobe-dubletten.txt")
|
||||||
|
a = p.parse_args(argv)
|
||||||
|
|
||||||
|
if not a.backfill and not a.slot:
|
||||||
|
p.error("--backfill oder --slot angeben")
|
||||||
|
|
||||||
|
slot = dt.datetime.fromisoformat(a.slot) if a.slot else None
|
||||||
|
begonnen = time.monotonic()
|
||||||
|
|
||||||
|
einstellungen = dict(EINSTELLUNGEN,
|
||||||
|
basis=a.basis,
|
||||||
|
jaccard_min=a.jaccard,
|
||||||
|
hamming_max=a.hamming,
|
||||||
|
fenster_tage=a.fenster,
|
||||||
|
mindestwoerter=MINDESTWOERTER[a.basis])
|
||||||
|
|
||||||
|
with verbindung() as conn:
|
||||||
|
# Die Einstellungen gehen in den Hash ein: eine andere Basis oder eine
|
||||||
|
# andere Schwelle ist eine andere Kodierung und darf die vorhandene
|
||||||
|
# nicht ueberschreiben.
|
||||||
|
v = versionsmodul.eintragen(
|
||||||
|
conn, komponenten=versionsmodul.komponenten(dubletten=einstellungen))
|
||||||
|
print(f"coder_version {v} basis={a.basis} j>={a.jaccard} h<={a.hamming}"
|
||||||
|
f"{' (trocken)' if a.trocken else ''}")
|
||||||
|
|
||||||
|
items, uf, ergebnis, schreibbereich = lade_und_gruppiere(
|
||||||
|
conn, slot=slot, fenster_tage=a.fenster, basis=a.basis,
|
||||||
|
jaccard_min=a.jaccard, hamming_max=a.hamming)
|
||||||
|
if not items:
|
||||||
|
print(" nichts zu tun")
|
||||||
|
return 0
|
||||||
|
bericht(items, ergebnis)
|
||||||
|
|
||||||
|
zahlen = schreibe(conn, items, ergebnis, uf, v, trocken=a.trocken,
|
||||||
|
schreibbereich=schreibbereich)
|
||||||
|
dauer = int((time.monotonic() - begonnen) * 1000)
|
||||||
|
print(f" codings: neu {zahlen['neu']}, geaendert {zahlen['geaendert']},"
|
||||||
|
f" unveraendert {zahlen['unveraendert']}"
|
||||||
|
+ (f", Kranz uebergangen {zahlen['ausserhalb']}" if zahlen["ausserhalb"] else ""))
|
||||||
|
print(f" Dauer: {dauer} ms")
|
||||||
|
|
||||||
|
if a.stichprobe:
|
||||||
|
n, gesamt = stichprobe(ergebnis, a.stichprobe, a.stichprobe_datei)
|
||||||
|
print(f" Stichprobe: {n} von {gesamt} Paaren nach {a.stichprobe_datei}")
|
||||||
|
|
||||||
|
if not a.trocken:
|
||||||
|
protokolliere(conn, slot, v, dauer, len(items),
|
||||||
|
zahlen["neu"] + zahlen["geaendert"])
|
||||||
|
conn.commit()
|
||||||
|
else:
|
||||||
|
conn.rollback()
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
-- Migration p2-002: pgvector und Ablage der Item-Embeddings
|
||||||
|
--
|
||||||
|
-- psql "$ADMIN_DATABASE_URL" -v ON_ERROR_STOP=1 -f migrations/p2-002-embeddings.sql
|
||||||
|
--
|
||||||
|
-- Setzt p2-001 voraus und ein Image, das pgvector mitbringt (siehe README):
|
||||||
|
-- docker.io/pgvector/pgvector:pg18-trixie. Ohne die Erweiterung bricht die
|
||||||
|
-- Migration in der ersten Zeile ab und hinterlaesst nichts.
|
||||||
|
--
|
||||||
|
-- Die Embeddings sind Vorfilter fuer die Kandidatengenerierung in Phase 3b,
|
||||||
|
-- kein Selbstzweck. Phase 2 clustert nicht.
|
||||||
|
|
||||||
|
begin;
|
||||||
|
|
||||||
|
create extension if not exists vector;
|
||||||
|
|
||||||
|
-- --------------------------------------------------------------------------
|
||||||
|
-- Der Vektor liegt bewusst nicht in codings.nutzlast: JSONB-Vektoren sind
|
||||||
|
-- weder indizierbar noch platzsparend. Die Coding-Zeile der Schicht
|
||||||
|
-- 'embedding' haelt nur die Kennung, der Vektor steht hier.
|
||||||
|
--
|
||||||
|
-- 1024 Dimensionen deckt beide in der Spezifikation genannten Modelle ab
|
||||||
|
-- (multilingual-e5-large, jina-embeddings-v3). Ein Modell mit anderer
|
||||||
|
-- Dimension braucht eine eigene Migration - das ist gewollt, denn es waere
|
||||||
|
-- ohnehin eine neue coder_version.
|
||||||
|
-- --------------------------------------------------------------------------
|
||||||
|
create table if not exists item_embeddings (
|
||||||
|
raw_item_id bigint not null references raw_items(id) on delete cascade,
|
||||||
|
coder_version text not null,
|
||||||
|
erzeugt_am timestamptz not null default now(),
|
||||||
|
vektor vector(1024) not null,
|
||||||
|
|
||||||
|
primary key (raw_item_id, coder_version)
|
||||||
|
);
|
||||||
|
|
||||||
|
comment on table item_embeddings is
|
||||||
|
'Satz-Embedding je Item und Kodierer-Version. Vorfilter fuer Phase 3b';
|
||||||
|
comment on column item_embeddings.coder_version is
|
||||||
|
'Ohne Fremdschluessel auf coder_versionen, wie codings auch - die Herkunft '
|
||||||
|
'steht dort, erzwungen wird sie nicht';
|
||||||
|
|
||||||
|
-- Der Fremdschluessel auf raw_items ist nicht Kosmetik: ohne ihn bleiben
|
||||||
|
-- Vektoren als Waisen liegen, wenn ein Item aus raw_items verschwindet.
|
||||||
|
|
||||||
|
-- --------------------------------------------------------------------------
|
||||||
|
-- Kein globaler HNSW-Index.
|
||||||
|
--
|
||||||
|
-- Ein Index ueber alle coder_version hinweg legt Vektoren verschiedener
|
||||||
|
-- Modelle in dieselbe Nachbarschaftsstruktur. Eine Suche muss dann nach
|
||||||
|
-- Version filtern, und pgvector filtert bei HNSW erst *nach* dem Durchlauf
|
||||||
|
-- der Kandidatenliste - das kostet Treffer, nicht nur Zeit.
|
||||||
|
--
|
||||||
|
-- Stattdessen ein partieller Index je Version, angelegt wenn die Version in
|
||||||
|
-- Betrieb geht:
|
||||||
|
--
|
||||||
|
-- select p2_embedding_index('p2-2026-09-07-a3f1b2c4');
|
||||||
|
--
|
||||||
|
-- Der Aufbau ist speicherhungrig; bei grossen Bestaenden vorher
|
||||||
|
-- set maintenance_work_mem = '2GB';
|
||||||
|
-- Und die Version wieder loswerden (Invariante 4, verwerfbar):
|
||||||
|
-- drop index if exists item_embeddings_hnsw_<version>;
|
||||||
|
-- delete from item_embeddings where coder_version = '...';
|
||||||
|
-- --------------------------------------------------------------------------
|
||||||
|
create or replace function p2_embedding_index(version text)
|
||||||
|
returns text language plpgsql as $$
|
||||||
|
declare
|
||||||
|
idx text;
|
||||||
|
begin
|
||||||
|
-- Nicht-alphanumerisches im Versionsnamen wird zu _, damit der
|
||||||
|
-- Indexname ohne Anfuehrungszeichen auskommt.
|
||||||
|
idx := 'item_embeddings_hnsw_' || regexp_replace(version, '[^a-zA-Z0-9]+', '_', 'g');
|
||||||
|
if to_regclass(idx) is not null then
|
||||||
|
return idx || ' (bestand bereits)';
|
||||||
|
end if;
|
||||||
|
execute format(
|
||||||
|
'create index %I on item_embeddings using hnsw (vektor vector_cosine_ops) where coder_version = %L',
|
||||||
|
idx, version);
|
||||||
|
return idx;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
comment on function p2_embedding_index is
|
||||||
|
'Legt den HNSW-Index fuer genau eine coder_version an. Idempotent';
|
||||||
|
|
||||||
|
insert into schema_migrationen (name) values ('p2-002-embeddings')
|
||||||
|
on conflict (name) do nothing;
|
||||||
|
|
||||||
|
commit;
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
-- Migration p2-003: Schemaobjekte der Anwendungsrolle uebereignen
|
||||||
|
--
|
||||||
|
-- psql "$ADMIN_DATABASE_URL" -v ON_ERROR_STOP=1 -f migrations/p2-003-eigentuemer.sql
|
||||||
|
--
|
||||||
|
-- Die Datenbank wurzelwerk gehoert der Rolle wurzelwerk, ihre Tabellen aber
|
||||||
|
-- postgres: schema.sql wurde beim Ingest-Deploy als Superuser eingespielt.
|
||||||
|
-- Folge ist, dass die Anwendungsrolle an ihrem eigenen Schema nichts aendern
|
||||||
|
-- darf - jedes `alter table` scheitert mit "must be owner of table".
|
||||||
|
--
|
||||||
|
-- Die uebrigen Dienste auf derselben Instanz halten es anders herum: gitea
|
||||||
|
-- und hedgedoc besitzen ihre Tabellen vollstaendig. Diese Migration stellt
|
||||||
|
-- denselben Zustand her. Der Radius bleibt auf die Datenbank wurzelwerk
|
||||||
|
-- beschraenkt; an gitea und hedgedoc wird nicht geruehrt.
|
||||||
|
--
|
||||||
|
-- Danach laufen Migrationen ohne Superuser, also ueber DATABASE_URL statt
|
||||||
|
-- ueber den Socket im Container. Ausnahme bleibt `create extension` in
|
||||||
|
-- p2-002, sofern die Erweiterung nicht als trusted gefuehrt wird.
|
||||||
|
--
|
||||||
|
-- Diese Migration ist die einzige, die zwingend als Superuser laeuft - sie
|
||||||
|
-- verschenkt Rechte, die sie selbst nicht mehr braeuchte.
|
||||||
|
|
||||||
|
begin;
|
||||||
|
|
||||||
|
do $$
|
||||||
|
declare
|
||||||
|
ziel constant name := 'wurzelwerk';
|
||||||
|
o record;
|
||||||
|
n integer := 0;
|
||||||
|
begin
|
||||||
|
if not exists (select 1 from pg_roles where rolname = ziel) then
|
||||||
|
raise exception 'Rolle % existiert nicht', ziel;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
-- Tabellen, Sichten und freistehende Sequenzen.
|
||||||
|
--
|
||||||
|
-- Ausgenommen sind zwei Gruppen. Erstens Objekte einer Erweiterung
|
||||||
|
-- (pg_trgm, vector): die gehoeren dem Superuser und werden mit der
|
||||||
|
-- Erweiterung verwaltet, nicht von Hand. Zweitens Sequenzen, die an
|
||||||
|
-- einer Spalte haengen - alles aus bigserial. Die folgen dem Eigentuemer
|
||||||
|
-- ihrer Tabelle von selbst und lassen sich nicht einzeln uebereignen
|
||||||
|
-- ("is linked to table"). Deshalb erst die Tabellen, dann der Rest.
|
||||||
|
for o in
|
||||||
|
select c.oid::regclass::text as name,
|
||||||
|
case c.relkind when 'S' then 'sequence'
|
||||||
|
when 'v' then 'view'
|
||||||
|
when 'm' then 'materialized view'
|
||||||
|
else 'table' end as art
|
||||||
|
from pg_class c join pg_namespace n on n.oid = c.relnamespace
|
||||||
|
where n.nspname = 'public'
|
||||||
|
and c.relkind in ('r', 'p', 'S', 'v', 'm')
|
||||||
|
and pg_get_userbyid(c.relowner) <> ziel
|
||||||
|
and not exists (select 1 from pg_depend d
|
||||||
|
where d.classid = 'pg_class'::regclass
|
||||||
|
and d.objid = c.oid and d.deptype = 'e')
|
||||||
|
and not exists (select 1 from pg_depend d
|
||||||
|
where d.classid = 'pg_class'::regclass
|
||||||
|
and d.objid = c.oid and d.deptype = 'a'
|
||||||
|
and d.refclassid = 'pg_class'::regclass)
|
||||||
|
order by case c.relkind when 'r' then 0 when 'p' then 0
|
||||||
|
when 'v' then 1 when 'm' then 1 else 2 end
|
||||||
|
loop
|
||||||
|
execute format('alter %s %s owner to %I', o.art, o.name, ziel);
|
||||||
|
n := n + 1;
|
||||||
|
end loop;
|
||||||
|
|
||||||
|
-- Funktionen ebenso. Ohne sie duerfte die Rolle ihre eigenen Helfer
|
||||||
|
-- spaeter nicht mehr ersetzen.
|
||||||
|
for o in
|
||||||
|
select p.oid::regprocedure::text as name
|
||||||
|
from pg_proc p join pg_namespace n on n.oid = p.pronamespace
|
||||||
|
where n.nspname = 'public'
|
||||||
|
and pg_get_userbyid(p.proowner) <> ziel
|
||||||
|
and not exists (select 1 from pg_depend d
|
||||||
|
where d.classid = 'pg_proc'::regclass
|
||||||
|
and d.objid = p.oid and d.deptype = 'e')
|
||||||
|
loop
|
||||||
|
execute format('alter function %s owner to %I', o.name, ziel);
|
||||||
|
n := n + 1;
|
||||||
|
end loop;
|
||||||
|
|
||||||
|
raise notice 'uebereignet: % Objekte an %', n, ziel;
|
||||||
|
end $$;
|
||||||
|
|
||||||
|
-- Neue Objekte gehoeren ohnehin dem, der sie anlegt; das Recht dazu im
|
||||||
|
-- Schema public ist seit PostgreSQL 15 nicht mehr selbstverstaendlich.
|
||||||
|
grant create, usage on schema public to wurzelwerk;
|
||||||
|
|
||||||
|
insert into schema_migrationen (name) values ('p2-003-eigentuemer')
|
||||||
|
on conflict (name) do nothing;
|
||||||
|
|
||||||
|
commit;
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
"""Textnormalisierung fuer Phase 2.
|
||||||
|
|
||||||
|
Gemeinsame Vorstufe der Module. Jede Aenderung hier aendert die
|
||||||
|
coder_version - siehe version.py.
|
||||||
|
|
||||||
|
Grundsatz: die Normalisierung ist rein und ohne Zustand. Gleiche Eingabe,
|
||||||
|
gleiche Ausgabe, auf jeder Maschine und in jedem Prozess.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import html
|
||||||
|
import re
|
||||||
|
import unicodedata
|
||||||
|
|
||||||
|
# Was hier steht, geht in den Versions-Hash ein.
|
||||||
|
REGELWERK_VERSION = "norm-3"
|
||||||
|
|
||||||
|
_TAGS = re.compile(r"<[^>]{1,200}>")
|
||||||
|
_MEHRFACH_LEER = re.compile(r"\s+")
|
||||||
|
|
||||||
|
# Anfuehrungszeichen und Striche vereinheitlichen. Verschiedene Haeuser setzen
|
||||||
|
# fuer dieselbe Agenturmeldung verschiedene Zeichen; ohne das scheitert schon
|
||||||
|
# die Trigrammbildung.
|
||||||
|
_ZEICHEN = str.maketrans({
|
||||||
|
"„": '"', "“": '"', "”": '"', "»": '"', "«": '"', "‟": '"',
|
||||||
|
"‚": "'", "‘": "'", "’": "'", "‹": "'", "›": "'", "´": "'", "`": "'",
|
||||||
|
"–": "-", "—": "-", "‑": "-", "‒": "-", "−": "-",
|
||||||
|
"…": "...", " ": " ", " ": " ", " ": " ", "": " ",
|
||||||
|
})
|
||||||
|
|
||||||
|
# "+++ Newsblog +++" und Verwandtes: redaktioneller Aufmacher, der nichts
|
||||||
|
# ueber den Inhalt sagt. Dieselbe Meldung traegt ihn bei einem Haus und beim
|
||||||
|
# naechsten nicht.
|
||||||
|
_PLUSKASTEN = re.compile(r"\+{2,}[^+]{0,120}?\+{2,}")
|
||||||
|
|
||||||
|
# Satzzeichen fallen weg: sie sind der haeufigste Unterschied zwischen zwei
|
||||||
|
# Fassungen derselben Meldung und tragen nichts zur Unterscheidung bei.
|
||||||
|
_KEIN_WORT = re.compile(r"[^\w\s]", re.UNICODE)
|
||||||
|
|
||||||
|
# Ressortkuerzel, das Haeuser ihren Titeln voranstellen: "Raumfahrt: Deutsche
|
||||||
|
# Rakete ...", "Notfaelle: Fuenf Tote ...", "Fussball: FIFA-Praesident ...".
|
||||||
|
# Dieselbe Agenturzeile bekommt bei jedem Haus ein anderes - oder gar keins.
|
||||||
|
#
|
||||||
|
# Bewusst eng gefasst: hoechstens vier Woerter vor dem Doppelpunkt, und was
|
||||||
|
# danach kommt, muss fuer sich stehen koennen. Sonst frisst das Muster
|
||||||
|
# Titel, bei denen der Teil vor dem Doppelpunkt die eigentliche Aussage
|
||||||
|
# traegt ("Habeck: Wir haben uns geirrt").
|
||||||
|
_RESSORT = re.compile(r"^\s*(?P<kopf>[^:]{2,34}):\s+(?P<rest>.+)$", re.DOTALL)
|
||||||
|
_RESSORT_MAX_WOERTER = 4
|
||||||
|
_REST_MIN_WOERTER = 5
|
||||||
|
|
||||||
|
|
||||||
|
def _ressort_abtrennen(text):
|
||||||
|
treffer = _RESSORT.match(text)
|
||||||
|
if not treffer:
|
||||||
|
return text
|
||||||
|
kopf, rest = treffer.group("kopf"), treffer.group("rest")
|
||||||
|
if len(kopf.split()) > _RESSORT_MAX_WOERTER:
|
||||||
|
return text
|
||||||
|
if len(rest.split()) < _REST_MIN_WOERTER:
|
||||||
|
return text
|
||||||
|
return rest
|
||||||
|
|
||||||
|
|
||||||
|
def normalisiere(*teile, ressort_abtrennen=False):
|
||||||
|
"""Vergleichstext aus einem oder mehreren Feldern.
|
||||||
|
|
||||||
|
Reihenfolge ist bedeutsam: Tags vor Entities, NFKC vor der
|
||||||
|
Zeichentabelle, Kleinschreibung vor den Mustern, und das Ressortkuerzel
|
||||||
|
vor dem Entfernen der Satzzeichen - danach gibt es keinen Doppelpunkt
|
||||||
|
mehr, an dem es zu erkennen waere.
|
||||||
|
|
||||||
|
ressort_abtrennen gilt nur fuer Titel. Im Teaser steht der Doppelpunkt
|
||||||
|
fuer gewoehnlich mitten im Satz.
|
||||||
|
"""
|
||||||
|
text = " ".join(t for t in teile if t)
|
||||||
|
if not text:
|
||||||
|
return ""
|
||||||
|
text = _TAGS.sub(" ", text)
|
||||||
|
text = html.unescape(text)
|
||||||
|
text = unicodedata.normalize("NFKC", text)
|
||||||
|
text = text.translate(_ZEICHEN)
|
||||||
|
text = text.casefold()
|
||||||
|
text = _PLUSKASTEN.sub(" ", text)
|
||||||
|
if ressort_abtrennen:
|
||||||
|
text = _ressort_abtrennen(text)
|
||||||
|
text = _KEIN_WORT.sub(" ", text)
|
||||||
|
return _MEHRFACH_LEER.sub(" ", text).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def woerter(text):
|
||||||
|
return text.split()
|
||||||
|
|
||||||
|
|
||||||
|
def trigramme(text):
|
||||||
|
"""Wort-Trigramme als Menge.
|
||||||
|
|
||||||
|
Menge, nicht Liste: der Jaccard-Wert braucht sie so, und Wiederholungen
|
||||||
|
innerhalb einer kurzen Meldung sind kein Signal.
|
||||||
|
"""
|
||||||
|
w = woerter(text)
|
||||||
|
if len(w) < 3:
|
||||||
|
# Zu kurz fuer Trigramme. Das Wort selbst ist besser als nichts,
|
||||||
|
# aber der Aufrufer prueft ohnehin auf Mindestlaenge.
|
||||||
|
return {(x,) for x in w}
|
||||||
|
return {tuple(w[i:i + 3]) for i in range(len(w) - 2)}
|
||||||
@@ -0,0 +1,349 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Testfaelle fuer Normalisierung und Dublettenerkennung.
|
||||||
|
|
||||||
|
python3 -m unittest test_dubletten -v
|
||||||
|
|
||||||
|
Ohne Datenbank. Was die Datenbank angeht, prueft dubletten.py selbst beim
|
||||||
|
Lauf: die Zahlen im Bericht sind die Kontrolle.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import datetime as dt
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
import dubletten as d
|
||||||
|
import normalisierung as n
|
||||||
|
|
||||||
|
|
||||||
|
class Normalisierung(unittest.TestCase):
|
||||||
|
def test_entities_und_tags(self):
|
||||||
|
self.assertEqual(n.normalisiere("<b>Habeck</b> & Co."), "habeck co")
|
||||||
|
|
||||||
|
def test_anfuehrungszeichen_vereinheitlicht(self):
|
||||||
|
self.assertEqual(n.normalisiere("„Wende“ und »Wende«"),
|
||||||
|
n.normalisiere('"Wende" und "Wende"'))
|
||||||
|
|
||||||
|
def test_gedankenstriche_vereinheitlicht(self):
|
||||||
|
self.assertEqual(n.normalisiere("Bund – Land"), n.normalisiere("Bund - Land"))
|
||||||
|
|
||||||
|
def test_nfkc(self):
|
||||||
|
# dieselbe Zeichenfolge einmal zusammengesetzt, einmal vorkomponiert
|
||||||
|
self.assertEqual(n.normalisiere("Grün"), n.normalisiere("Grün"))
|
||||||
|
|
||||||
|
def test_pluskasten_faellt_weg(self):
|
||||||
|
self.assertEqual(n.normalisiere("+++ Newsblog +++ Kanzler tritt zurueck"),
|
||||||
|
n.normalisiere("Kanzler tritt zurueck"))
|
||||||
|
|
||||||
|
def test_trigramme(self):
|
||||||
|
self.assertEqual(n.trigramme("a b c d"), {("a", "b", "c"), ("b", "c", "d")})
|
||||||
|
|
||||||
|
|
||||||
|
class SimHash(unittest.TestCase):
|
||||||
|
def test_gleicher_text_gleicher_hash(self):
|
||||||
|
t = n.trigramme(n.normalisiere("Der Bundestag hat das Gesetz beschlossen"))
|
||||||
|
self.assertEqual(d.simhash(t), d.simhash(t))
|
||||||
|
|
||||||
|
def test_reproduzierbar_ueber_prozesse(self):
|
||||||
|
# Der Wert ist festgeschrieben. Aendert sich die Hashfunktion oder die
|
||||||
|
# Normalisierung, faellt dieser Test - und dann muss die
|
||||||
|
# coder_version steigen, sonst stimmen alte Codings nicht mehr.
|
||||||
|
t = n.trigramme(n.normalisiere("Der Bundestag hat das Gesetz beschlossen"))
|
||||||
|
self.assertEqual(f"{d.simhash(t):016x}", ERWARTETER_SIMHASH)
|
||||||
|
|
||||||
|
def test_kleine_aenderung_naeher_als_fremder_text(self):
|
||||||
|
"""SimHash ordnet richtig - aber nicht so scharf, wie man hofft.
|
||||||
|
|
||||||
|
Ein einziges geaendertes Wort verruecken drei Trigramme, und bei
|
||||||
|
einer Meldung aus wenigen Dutzend Woertern sind das schnell zehn Bit
|
||||||
|
Abstand. Die Grenze von 3 aus der Spezifikation verlangt darum
|
||||||
|
praktisch identischen Text; sie ist eine Praezisions-, keine
|
||||||
|
Aehnlichkeitsschwelle. Der Test haelt nur die Ordnung fest.
|
||||||
|
"""
|
||||||
|
basis = "Der Bundestag hat das Gesetz am Freitag beschlossen und damit den Weg frei gemacht"
|
||||||
|
nah = basis.replace("frei gemacht", "freigemacht")
|
||||||
|
fern = "Ein Sturmtief zieht am Wochenende ueber Norddeutschland hinweg und bringt Regen"
|
||||||
|
h = lambda x, y: d.hamming(d.simhash(n.trigramme(n.normalisiere(x))),
|
||||||
|
d.simhash(n.trigramme(n.normalisiere(y))))
|
||||||
|
self.assertEqual(h(basis, basis), 0)
|
||||||
|
self.assertLess(h(basis, nah), h(basis, fern))
|
||||||
|
|
||||||
|
def test_verschiedene_texte_grosse_distanz(self):
|
||||||
|
a = d.simhash(n.trigramme(n.normalisiere(
|
||||||
|
"Der Bundestag hat das Gesetz am Freitag beschlossen")))
|
||||||
|
b = d.simhash(n.trigramme(n.normalisiere(
|
||||||
|
"Ein Sturmtief zieht am Wochenende ueber Norddeutschland hinweg")))
|
||||||
|
self.assertGreater(d.hamming(a, b), 3)
|
||||||
|
|
||||||
|
def test_baender_schubfachschluss(self):
|
||||||
|
# Zwei Werte mit genau 3 abweichenden Bits muessen sich mindestens
|
||||||
|
# ein Band teilen - darauf beruht die Kandidatensuche.
|
||||||
|
a = 0
|
||||||
|
b = 0b111
|
||||||
|
gemeinsam = set(d.baender(a)) & set(d.baender(b))
|
||||||
|
self.assertTrue(gemeinsam)
|
||||||
|
|
||||||
|
|
||||||
|
class Jaccard(unittest.TestCase):
|
||||||
|
def test_identisch(self):
|
||||||
|
s = {("a", "b", "c")}
|
||||||
|
self.assertEqual(d.jaccard(s, s), 1.0)
|
||||||
|
|
||||||
|
def test_disjunkt(self):
|
||||||
|
self.assertEqual(d.jaccard({("a",)}, {("b",)}), 0.0)
|
||||||
|
|
||||||
|
def test_leer(self):
|
||||||
|
self.assertEqual(d.jaccard(set(), {("a",)}), 0.0)
|
||||||
|
|
||||||
|
|
||||||
|
class UnionFind(unittest.TestCase):
|
||||||
|
def test_transitiv(self):
|
||||||
|
uf = d.UnionFind()
|
||||||
|
uf.vereinige(3, 1)
|
||||||
|
uf.vereinige(2, 3)
|
||||||
|
self.assertEqual(uf.finde(1), uf.finde(2))
|
||||||
|
|
||||||
|
def test_reihenfolge_egal(self):
|
||||||
|
a, b = d.UnionFind(), d.UnionFind()
|
||||||
|
for x, y in [(1, 2), (2, 3), (4, 5)]:
|
||||||
|
a.vereinige(x, y)
|
||||||
|
for x, y in [(4, 5), (3, 2), (2, 1)]:
|
||||||
|
b.vereinige(x, y)
|
||||||
|
self.assertEqual([a.finde(i) for i in range(1, 6)],
|
||||||
|
[b.finde(i) for i in range(1, 6)])
|
||||||
|
|
||||||
|
|
||||||
|
class Ressortkuerzel(unittest.TestCase):
|
||||||
|
"""Das Kuerzel, das Haeuser der Agenturzeile voranstellen.
|
||||||
|
|
||||||
|
Es abzutrennen ist der Unterschied zwischen "findet die Uebernahme" und
|
||||||
|
"findet sie nicht" - siehe NOTES.md.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def test_kuerzel_faellt_weg(self):
|
||||||
|
self.assertEqual(
|
||||||
|
n.normalisiere("Raumfahrt: Deutsche Rakete erfolgreich ins All gestartet",
|
||||||
|
ressort_abtrennen=True),
|
||||||
|
n.normalisiere("Deutsche Rakete erfolgreich ins All gestartet",
|
||||||
|
ressort_abtrennen=True))
|
||||||
|
|
||||||
|
def test_verschiedene_kuerzel_gleicher_kern(self):
|
||||||
|
a = n.normalisiere("Notfaelle: Fuenf Tote bei Unfall mit Frachtflugzeug in Miami",
|
||||||
|
ressort_abtrennen=True)
|
||||||
|
b = n.normalisiere("Flugverkehr: Fuenf Tote bei Unfall mit Frachtflugzeug in Miami",
|
||||||
|
ressort_abtrennen=True)
|
||||||
|
self.assertEqual(a, b)
|
||||||
|
|
||||||
|
def test_kurzer_rest_bleibt_unangetastet(self):
|
||||||
|
# "Habeck: Wir haben uns geirrt" - der Kopf ist die Aussage, nicht
|
||||||
|
# ein Ressort. Vier Woerter Rest sind zu wenig, um ihn wegzuwerfen.
|
||||||
|
self.assertIn("habeck", n.normalisiere("Habeck: Wir haben uns geirrt",
|
||||||
|
ressort_abtrennen=True))
|
||||||
|
|
||||||
|
def test_langer_kopf_bleibt_unangetastet(self):
|
||||||
|
titel = "Die lange Vorrede mit sehr vielen Woertern: der eigentliche Kern der Meldung"
|
||||||
|
self.assertIn("vorrede", n.normalisiere(titel, ressort_abtrennen=True))
|
||||||
|
|
||||||
|
def test_ohne_schalter_bleibt_alles_stehen(self):
|
||||||
|
self.assertIn("raumfahrt", n.normalisiere("Raumfahrt: Deutsche Rakete gestartet ins All"))
|
||||||
|
|
||||||
|
def test_teaser_behaelt_seine_doppelpunkte(self):
|
||||||
|
# Im Teaser steht der Doppelpunkt mitten im Satz; die Basis
|
||||||
|
# titel+teaser trennt darum nichts ab.
|
||||||
|
i = _item(1, "Ein Titel ueber irgendetwas Wichtiges",
|
||||||
|
"Der Minister sagte: das sei nicht hinnehmbar und muesse aufhoeren",
|
||||||
|
basis="titel+teaser")
|
||||||
|
self.assertIn("minister", i.text)
|
||||||
|
|
||||||
|
|
||||||
|
class TitelBasis(unittest.TestCase):
|
||||||
|
def test_uebernahme_mit_verschiedenen_kuerzeln_findet_zusammen(self):
|
||||||
|
items = [_item(1, "Raumfahrt: Deutsche Rakete erfolgreich ins All gestartet",
|
||||||
|
"Ein Teaser des einen Hauses ueber den Start.",
|
||||||
|
quelle="heise", basis="titel"),
|
||||||
|
_item(2, "Deutsche Rakete erfolgreich ins All gestartet",
|
||||||
|
"Ein voellig anders formulierter Teaser des anderen Hauses.",
|
||||||
|
quelle="tagesschau", basis="titel")]
|
||||||
|
uf, _ = d.gruppiere(items)
|
||||||
|
self.assertEqual(uf.finde(1), uf.finde(2))
|
||||||
|
|
||||||
|
def test_dieselben_items_ueber_titel_und_teaser_finden_nicht_zusammen(self):
|
||||||
|
# Der Gegenbeweis zur Messung: mit Teaser im Sack faellt das Paar
|
||||||
|
# unter die Schwelle.
|
||||||
|
items = [_item(1, "Raumfahrt: Deutsche Rakete erfolgreich ins All gestartet",
|
||||||
|
"Ein Teaser des einen Hauses ueber den Start.",
|
||||||
|
quelle="heise", basis="titel+teaser"),
|
||||||
|
_item(2, "Deutsche Rakete erfolgreich ins All gestartet",
|
||||||
|
"Ein voellig anders formulierter Teaser des anderen Hauses.",
|
||||||
|
quelle="tagesschau", basis="titel+teaser")]
|
||||||
|
uf, _ = d.gruppiere(items)
|
||||||
|
self.assertNotEqual(uf.finde(1), uf.finde(2))
|
||||||
|
|
||||||
|
def test_verschiedene_meldungen_bleiben_getrennt(self):
|
||||||
|
items = [_item(1, "Bundestag beschliesst Reform der Netzentgelte am Freitag",
|
||||||
|
quelle="faz", basis="titel"),
|
||||||
|
_item(2, "Sturmtief Xaver erreicht am Abend die Nordseekueste",
|
||||||
|
quelle="ntv", basis="titel")]
|
||||||
|
uf, _ = d.gruppiere(items)
|
||||||
|
self.assertNotEqual(uf.finde(1), uf.finde(2))
|
||||||
|
|
||||||
|
def test_kurzer_titel_wird_nicht_verglichen(self):
|
||||||
|
i = _item(1, "Kanzler tritt zurueck", basis="titel")
|
||||||
|
self.assertFalse(i.vergleichbar)
|
||||||
|
|
||||||
|
|
||||||
|
class Agentur(unittest.TestCase):
|
||||||
|
def test_klammer(self):
|
||||||
|
self.assertEqual(d.agentur("Berlin (dpa) - etwas geschah"), "dpa")
|
||||||
|
|
||||||
|
def test_reuters(self):
|
||||||
|
self.assertEqual(d.agentur("Meldung", "Wie Reuters berichtet"), "reuters")
|
||||||
|
|
||||||
|
def test_ohne_beleg_keine_vermutung(self):
|
||||||
|
self.assertIsNone(d.agentur("Der Bundestag hat entschieden"))
|
||||||
|
|
||||||
|
|
||||||
|
def _item(id, titel, teaser=None, quelle="x", tag=1, gesehen_h=0, basis="titel+teaser"):
|
||||||
|
return d.Item(id, quelle, titel, teaser,
|
||||||
|
dt.datetime(2026, 9, tag, gesehen_h, tzinfo=dt.timezone.utc),
|
||||||
|
dt.date(2026, 9, tag), f"hash{id}", basis=basis)
|
||||||
|
|
||||||
|
|
||||||
|
AGENTURMELDUNG = ("Bundestag beschliesst Reform der Netzentgelte",
|
||||||
|
"Der Bundestag hat am Freitag die Reform der Netzentgelte beschlossen. "
|
||||||
|
"Die Kosten sollen kuenftig gleichmaessiger verteilt werden.")
|
||||||
|
|
||||||
|
|
||||||
|
class Gruppierung(unittest.TestCase):
|
||||||
|
def test_gleiche_meldung_zwei_haeuser(self):
|
||||||
|
items = [_item(1, *AGENTURMELDUNG, quelle="faz"),
|
||||||
|
_item(2, *AGENTURMELDUNG, quelle="welt")]
|
||||||
|
uf, erg = d.gruppiere(items)
|
||||||
|
self.assertEqual(uf.finde(1), uf.finde(2))
|
||||||
|
|
||||||
|
def test_verschiedene_meldungen_bleiben_getrennt(self):
|
||||||
|
items = [
|
||||||
|
_item(1, *AGENTURMELDUNG),
|
||||||
|
_item(2, "Sturmtief Xaver erreicht die Nordseekueste",
|
||||||
|
"Der Deutsche Wetterdienst warnt vor Orkanboeen an der Kueste "
|
||||||
|
"und rechnet mit Sturmfluten am Abend.", quelle="ntv"),
|
||||||
|
]
|
||||||
|
uf, erg = d.gruppiere(items)
|
||||||
|
self.assertNotEqual(uf.finde(1), uf.finde(2))
|
||||||
|
|
||||||
|
def test_datumsfenster_trennt(self):
|
||||||
|
items = [_item(1, *AGENTURMELDUNG, tag=1), _item(2, *AGENTURMELDUNG, tag=5)]
|
||||||
|
uf, erg = d.gruppiere(items)
|
||||||
|
self.assertNotEqual(uf.finde(1), uf.finde(2))
|
||||||
|
|
||||||
|
def test_zu_kurze_items_werden_nicht_gepaart(self):
|
||||||
|
items = [_item(1, "Kanzler tritt zurueck"), _item(2, "Kanzler tritt zurueck")]
|
||||||
|
self.assertFalse(items[0].vergleichbar)
|
||||||
|
uf, erg = d.gruppiere(items)
|
||||||
|
self.assertNotEqual(uf.finde(1), uf.finde(2))
|
||||||
|
|
||||||
|
def test_leitartikel_ist_das_aelteste(self):
|
||||||
|
items = [_item(9, *AGENTURMELDUNG, gesehen_h=8),
|
||||||
|
_item(4, *AGENTURMELDUNG, gesehen_h=3, quelle="b"),
|
||||||
|
_item(7, *AGENTURMELDUNG, gesehen_h=3, quelle="c")]
|
||||||
|
uf, erg = d.gruppiere(items)
|
||||||
|
gruppe = erg[uf.finde(9)]
|
||||||
|
self.assertEqual(gruppe["leitartikel"].id, 4) # frueheste Zeit, dann kleinste id
|
||||||
|
|
||||||
|
def test_gruppen_id_haengt_nur_am_leitartikel(self):
|
||||||
|
# Das ist die Zusage aus gruppen_id(): ein spaeter hinzukommendes
|
||||||
|
# Mitglied darf die Kennung der Gruppe nicht veraendern.
|
||||||
|
zwei = [_item(1, *AGENTURMELDUNG, gesehen_h=1),
|
||||||
|
_item(2, *AGENTURMELDUNG, gesehen_h=2, quelle="b")]
|
||||||
|
drei = zwei + [_item(3, *AGENTURMELDUNG, gesehen_h=9, quelle="c")]
|
||||||
|
uf2, e2 = d.gruppiere(zwei)
|
||||||
|
uf3, e3 = d.gruppiere(drei)
|
||||||
|
self.assertEqual(d.nutzlast(zwei[0], e2[uf2.finde(1)])["gruppe"],
|
||||||
|
d.nutzlast(drei[0], e3[uf3.finde(1)])["gruppe"])
|
||||||
|
|
||||||
|
def test_kette_ueber_tage_wird_getrennt(self):
|
||||||
|
"""Taegliche Wiederholung darf nicht zu einer Gruppe verschmelzen.
|
||||||
|
|
||||||
|
Drei gleichlautende Sendungen an drei aufeinanderfolgenden Tagen
|
||||||
|
haengen paarweise im Fenster zusammen. Transitiv waeren sie eine
|
||||||
|
Gruppe ueber drei Tage - das ist keine Uebernahme.
|
||||||
|
"""
|
||||||
|
items = [_item(1, *AGENTURMELDUNG, tag=1),
|
||||||
|
_item(2, *AGENTURMELDUNG, tag=2),
|
||||||
|
_item(3, *AGENTURMELDUNG, tag=3),
|
||||||
|
_item(4, *AGENTURMELDUNG, tag=4)]
|
||||||
|
uf, erg = d.gruppiere(items)
|
||||||
|
gruppen = {uf.finde(i.id) for i in items}
|
||||||
|
self.assertGreater(len(gruppen), 1)
|
||||||
|
for g in erg.values():
|
||||||
|
tage = [i.datum for i in g["mitglieder"]]
|
||||||
|
self.assertLessEqual((max(tage) - min(tage)).days, 1)
|
||||||
|
|
||||||
|
def test_gleiches_haus_an_anderen_tagen_nicht_gepaart(self):
|
||||||
|
"""Wiederkehrendes Format ist keine Uebernahme.
|
||||||
|
|
||||||
|
Diese Regel sitzt auf Paarebene, damit sie fensterunabhaengig ist -
|
||||||
|
sie ist der Grund, dass ein Slot-Lauf dieselben Gruppen liefert wie
|
||||||
|
ein Backfill.
|
||||||
|
"""
|
||||||
|
selbe = [_item(1, *AGENTURMELDUNG, quelle="tagesschau", tag=1),
|
||||||
|
_item(2, *AGENTURMELDUNG, quelle="tagesschau", tag=2)]
|
||||||
|
uf, _ = d.gruppiere(selbe)
|
||||||
|
self.assertNotEqual(uf.finde(1), uf.finde(2))
|
||||||
|
|
||||||
|
# am selben Tag dagegen schon
|
||||||
|
selber_tag = [_item(1, *AGENTURMELDUNG, quelle="tagesschau", tag=1),
|
||||||
|
_item(2, *AGENTURMELDUNG, quelle="tagesschau", tag=1)]
|
||||||
|
uf, _ = d.gruppiere(selber_tag)
|
||||||
|
self.assertEqual(uf.finde(1), uf.finde(2))
|
||||||
|
|
||||||
|
# verschiedene Haeuser an benachbarten Tagen bleiben eine Uebernahme
|
||||||
|
haeuser = [_item(1, *AGENTURMELDUNG, quelle="faz", tag=1),
|
||||||
|
_item(2, *AGENTURMELDUNG, quelle="welt", tag=2)]
|
||||||
|
uf, _ = d.gruppiere(haeuser)
|
||||||
|
self.assertEqual(uf.finde(1), uf.finde(2))
|
||||||
|
|
||||||
|
def test_haeuserzahl_in_der_nutzlast(self):
|
||||||
|
# Eine Gruppe aus einem Haus ist Wiederholung, keine Uebernahme.
|
||||||
|
items = [_item(1, *AGENTURMELDUNG, quelle="faz"),
|
||||||
|
_item(2, *AGENTURMELDUNG, quelle="welt")]
|
||||||
|
uf, erg = d.gruppiere(items)
|
||||||
|
self.assertEqual(d.nutzlast(items[0], erg[uf.finde(1)])["haeuser"], 2)
|
||||||
|
eigen = [_item(1, *AGENTURMELDUNG, quelle="faz"),
|
||||||
|
_item(2, *AGENTURMELDUNG, quelle="faz")]
|
||||||
|
uf, erg = d.gruppiere(eigen)
|
||||||
|
self.assertEqual(d.nutzlast(eigen[0], erg[uf.finde(1)])["haeuser"], 1)
|
||||||
|
|
||||||
|
def test_einzelstueck_bekommt_eigene_gruppe(self):
|
||||||
|
items = [_item(1, *AGENTURMELDUNG)]
|
||||||
|
uf, erg = d.gruppiere(items)
|
||||||
|
last = d.nutzlast(items[0], erg[uf.finde(1)])
|
||||||
|
self.assertEqual(last["gruppengroesse"], 1)
|
||||||
|
self.assertEqual(last["leitartikel"], 1)
|
||||||
|
self.assertIsNone(last["jaccard_min"])
|
||||||
|
|
||||||
|
def test_nutzlast_haelt_den_textstand_fest(self):
|
||||||
|
items = [_item(1, *AGENTURMELDUNG)]
|
||||||
|
uf, erg = d.gruppiere(items)
|
||||||
|
last = d.nutzlast(items[0], erg[uf.finde(1)])
|
||||||
|
self.assertEqual(last["basis_hash"], "hash1")
|
||||||
|
self.assertEqual(last["basis"], "titel+teaser")
|
||||||
|
# und bei der anderen Basis steht auch die andere drin
|
||||||
|
nur_titel = [_item(1, *AGENTURMELDUNG, basis="titel")]
|
||||||
|
uf2, erg2 = d.gruppiere(nur_titel)
|
||||||
|
self.assertEqual(d.nutzlast(nur_titel[0], erg2[uf2.finde(1)])["basis"], "titel")
|
||||||
|
|
||||||
|
def test_nutzlast_ohne_langen_text(self):
|
||||||
|
# Abnahmekriterium: kein Text > 100 Zeichen in codings.nutzlast.
|
||||||
|
# Die Datenbank erzwingt es; hier faellt es frueher auf.
|
||||||
|
items = [_item(1, *AGENTURMELDUNG)]
|
||||||
|
uf, erg = d.gruppiere(items)
|
||||||
|
for wert in d.nutzlast(items[0], erg[uf.finde(1)]).values():
|
||||||
|
if isinstance(wert, str):
|
||||||
|
self.assertLessEqual(len(wert), 100)
|
||||||
|
|
||||||
|
|
||||||
|
# Festwert. Faellt dieser Test, hat sich das Verfahren geaendert und die
|
||||||
|
# coder_version muss steigen.
|
||||||
|
ERWARTETER_SIMHASH = "91c3912900958489"
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
+69
@@ -0,0 +1,69 @@
|
|||||||
|
"""coder_version: was hinter dem Hash steht.
|
||||||
|
|
||||||
|
Format aus der Spezifikation: p2-<ISO-Datum>-<hash8>.
|
||||||
|
|
||||||
|
Der Hash bildet alle Bestandteile, die das Ergebnis beeinflussen. Aendert
|
||||||
|
sich einer, aendert sich die Version, und alte Codings bleiben unangetastet
|
||||||
|
liegen. Das Datum ist das der letzten Aenderung an den Komponenten, nicht das
|
||||||
|
des Laufs - sonst waere die Version taeglich neu.
|
||||||
|
|
||||||
|
Anmerkung zur Spezifikation: sie sieht *eine* Version ueber alle fuenf Module
|
||||||
|
vor. Ein neues Sentimentlexikon entwertet damit auch die Embeddings, obwohl
|
||||||
|
es sie nicht beruehrt. Das ist teuer, aber es ist die festgelegte Semantik;
|
||||||
|
wer das aendern will, aendert die Spezifikation, nicht diese Datei.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
|
||||||
|
import normalisierung
|
||||||
|
|
||||||
|
# Stand der Komponenten. Module, die es noch nicht gibt, stehen auf null -
|
||||||
|
# sie tragen dann nichts zum Hash bei, ausser dass sie erwaehnt sind.
|
||||||
|
KOMPONENTEN = {
|
||||||
|
"stand": "2026-09-07",
|
||||||
|
"normalisierung": normalisierung.REGELWERK_VERSION,
|
||||||
|
# Wird vom jeweiligen Modul beim Lauf eingesetzt (siehe komponenten()).
|
||||||
|
# Die Einstellungen stehen dort, wo sie wirken, nicht hier - sonst
|
||||||
|
# gerieten die beiden auseinander, und der Hash behauptete eine
|
||||||
|
# Gleichheit, die es nicht gibt.
|
||||||
|
"dubletten": None,
|
||||||
|
"akteure": None,
|
||||||
|
"geo": None,
|
||||||
|
"tonalitaet": None,
|
||||||
|
"embedding": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def komponenten(**module):
|
||||||
|
"""Kopie der Komponenten mit eingesetzten Modulangaben.
|
||||||
|
|
||||||
|
Ein Modul kennt seine Einstellungen selbst und reicht sie hier herein.
|
||||||
|
Aendert es eine Schwelle, aendert sich der Hash - genau so ist es
|
||||||
|
gemeint.
|
||||||
|
"""
|
||||||
|
k = dict(KOMPONENTEN)
|
||||||
|
k.update(module)
|
||||||
|
return k
|
||||||
|
|
||||||
|
|
||||||
|
def coder_version(komponenten=None):
|
||||||
|
k = KOMPONENTEN if komponenten is None else komponenten
|
||||||
|
# sort_keys: die Reihenfolge im Quelltext darf den Hash nicht bewegen.
|
||||||
|
roh = json.dumps(k, sort_keys=True, ensure_ascii=False, separators=(",", ":"))
|
||||||
|
hash8 = hashlib.blake2b(roh.encode("utf-8"), digest_size=4).hexdigest()
|
||||||
|
return f"p2-{k['stand']}-{hash8}"
|
||||||
|
|
||||||
|
|
||||||
|
def eintragen(conn, komponenten=None):
|
||||||
|
"""Version in coder_versionen festhalten. Ohne das sagt der Hash nichts."""
|
||||||
|
from psycopg.types.json import Jsonb
|
||||||
|
|
||||||
|
k = KOMPONENTEN if komponenten is None else komponenten
|
||||||
|
v = coder_version(k)
|
||||||
|
conn.execute(
|
||||||
|
"insert into coder_versionen (version, komponenten) values (%s, %s)"
|
||||||
|
" on conflict (version) do nothing",
|
||||||
|
(v, Jsonb(k)),
|
||||||
|
)
|
||||||
|
return v
|
||||||
Reference in New Issue
Block a user