Drei Schritte, die zusammen Phase 2 lauffaehig machen.
Revisionen (migrations/p2-004, revisionen.py)
Ingest schreibt jede geaenderte Fassung eines Items nach
raw_item_versionen fort. Daraus faellt ein Befund ab, den die
Spezifikation nicht vorsehen konnte: welches Haus seine Schlagzeile
nachtraeglich umschreibt, und wie. 206 von 3407 Items, acht
Einstufungen von 'formal' bis 'neufassung'. Kein Modell, kein Lexikon,
kein Netzzugriff - nur ein Wortvergleich zweier Fassungen.
Fortschreibungen sind ueber ticker_markiert und kopf_stabil abziehbar,
absichtlich als zwei Merkmale statt einer Kategorie: das eine ist eine
Selbstauskunft des Hauses, das andere eine Beobachtung am Satzbau.
Embeddings (embeddings.py)
multilingual-e5-large, 1024 Dimensionen, MIT. Jede Zeile wird auf feste
128 Token aufgefuellt, nicht nur bis zur laengsten des Stapels - sonst
haengt ein Vektor davon ab, mit welchen Nachbarn er kodiert wurde, und
ein Slot-Lauf liefert andere letzte Stellen als ein Backfill.
Nachgewiesen: 64/64 bitgleich einzeln wie im vollen Stapel, groesste
Abweichung 0.00e+00, und im Betrieb 26 Slot-Vektoren unveraendert
gegen den 3407er-Backfill.
Darum transformers statt sentence-transformers: letzteres kapselt genau
diese Stelle weg.
Container (Containerfile, phase2.py, deploy/)
Debian trixie wie der Postgres-Container, torch aus dem CPU-Index.
Die regelbasierten Module liefern darin fuer alle 3407 Items bitgleich
dieselbe Nutzlast wie auf der Entwicklungsmaschine - Portabilitaet
gemessen, nicht behauptet.
version.py bekommt den Schluessel 'revision'. Weil der Hash ueber den ganzen
Komponentensatz laeuft, aendert das auch die Version des Dublettenmoduls,
obwohl an dessen Verfahren nichts anders ist. Das ist die in version.py
beschriebene Semantik der Spezifikation, kein Versehen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NupWEBPzsyohVPG47HsTz8
515 lines
20 KiB
Python
515 lines
20 KiB
Python
#!/usr/bin/env python3
|
|
"""Phase 2, Modul: nachtraeglich geaenderte Schlagzeilen (ebene = 'revision').
|
|
|
|
Ingest schreibt jede geaenderte Fassung eines Items nach raw_item_versionen
|
|
fort. Dieses Modul vergleicht die Fassungen und sagt, *wie* ein Haus seine
|
|
Schlagzeile umgeschrieben hat: Tippfehler, Kuerzung, Zuspitzung, Neufassung.
|
|
|
|
python3 revisionen.py --backfill
|
|
python3 revisionen.py --slot 2026-09-07T08:00
|
|
python3 revisionen.py --backfill --trocken --stichprobe 120
|
|
|
|
Anders als beim Dublettenmodul haengt eine Zeile hier allein an den Fassungen
|
|
*ihres eigenen* Items - es gibt keine Nachbarschaft, kein Datumsfenster und
|
|
darum auch keinen Rand, der mitgeladen werden muesste. Ein Slot-Lauf rechnet
|
|
fuer jedes beruehrte Item genau das, was ein Backfill fuer dasselbe Item
|
|
rechnet (Invariante 2). Er muss nur die Items finden, von denen im Slot eine
|
|
neue Fassung eingetroffen ist.
|
|
|
|
Der Befund ist ausserhalb der Spezifikation; die Begruendung steht in
|
|
migrations/p2-004-revisionen.sql.
|
|
"""
|
|
|
|
import argparse
|
|
import collections
|
|
import datetime as dt
|
|
import difflib
|
|
import re
|
|
import sys
|
|
import time
|
|
|
|
from psycopg.types.json import Jsonb
|
|
|
|
import normalisierung
|
|
import version as versionsmodul
|
|
from db import verbindung
|
|
|
|
EBENE = "revision"
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Wortlisten in der Nutzlast
|
|
#
|
|
# codings darf keine Werkausschnitte enthalten (Constraint
|
|
# codings_kein_volltext, hoechstens 100 Zeichen je Zeichenkette). Einzelne
|
|
# hinzugefuegte und gestrichene Woerter sind keine Ausschnitte, sondern der
|
|
# Befund selbst - ohne sie waere die Angabe "umformulierung" nicht
|
|
# nachpruefbar. Gekappt wird trotzdem: aus zwei Wortlisten von je hoechstens
|
|
# einem Dutzend Woertern laesst sich keine Schlagzeile zurueckbauen.
|
|
# --------------------------------------------------------------------------
|
|
WOERTER_MAX = 12
|
|
|
|
# Grenze zwischen "umgeschrieben" und "neu geschrieben". 0.4 ist gesetzt,
|
|
# nicht gemessen: unterhalb davon teilen zwei Schlagzeilen weniger als die
|
|
# Haelfte ihrer Woerter, und von der alten Aussage bleibt nichts stehen.
|
|
AEHNLICHKEIT_MIN = 0.4
|
|
|
|
# Tippfehler: genau ein Wort weicht ab, und die beiden Woerter sind einander
|
|
# so aehnlich, dass es eine Korrektur und keine Ersetzung ist. Die
|
|
# Laengengrenze traegt dabei die Arbeit - ohne sie gilt auch
|
|
# "ARD-Sondersendung" -> "ARD-Sendung" als Tippfehler, und das ist eine
|
|
# Kuerzung.
|
|
TIPPFEHLER_RATIO = 0.7
|
|
TIPPFEHLER_LAENGENDELTA = 2
|
|
|
|
EINSTELLUNGEN = {
|
|
"basis": "titel",
|
|
"diff": "wort-sequenzvergleich",
|
|
"aehnlichkeit_min": AEHNLICHKEIT_MIN,
|
|
"tippfehler_ratio": TIPPFEHLER_RATIO,
|
|
"tippfehler_laengendelta": TIPPFEHLER_LAENGENDELTA,
|
|
"woerter_max": WOERTER_MAX,
|
|
# Siehe normalisierung.normalisiere(): der Pluskasten *ist* hier die
|
|
# Schlagzeile, nicht ihr Beiwerk.
|
|
"pluskasten_entfernt": False,
|
|
"teaser_mitklassifiziert": True,
|
|
}
|
|
|
|
# Formatmarken fuer fortlaufend fortgeschriebene Artikel. Ein Liveticker
|
|
# wechselt seine Ueberschrift stuendlich, ohne dass eine Redaktion ihre
|
|
# Darstellung revidiert haette - fuer die Frage, wer nachtraeglich
|
|
# umformuliert, ist er Rauschen und muss abziehbar sein.
|
|
#
|
|
# Wortgrenzen sind noetig: "Liverpool" enthaelt "live".
|
|
_TICKER = re.compile(
|
|
r"\blive(?:ticker|blog|stream)\b|\bnewsblog\b|\bticker\b|\blive\b\s*[-:]",
|
|
re.IGNORECASE)
|
|
|
|
# Bewusst nicht ueber das +++-Muster: handelsblatt setzt "+++ USA +++:" als
|
|
# Ressortmarke, nicht als Tickerkennzeichen. Die tagesschau fuehrt ihren
|
|
# Ticker zwar in "++ ... ++", schreibt aber "Liveticker" hinein und wird
|
|
# darueber erkannt.
|
|
|
|
_ZAHL = re.compile(r"\d+")
|
|
|
|
# Text vor dem ersten Doppelpunkt - die Rubrik, unter der ein Haus einen
|
|
# Artikel fuehrt: "Liveblog zur Wahl in Sachsen-Anhalt: ...".
|
|
#
|
|
# Absichtlich nicht normalisierung.ressort_teilen(): das prueft zusaetzlich,
|
|
# ob der Kopf hoechstens vier Woerter misst und dahinter noch ein
|
|
# vollstaendiger Satz steht. Diese Vorsicht ist dort noetig, wo der Kopf
|
|
# *abgeschnitten* wird - "Habeck: Wir haben uns geirrt" darf seinen nicht
|
|
# verlieren. Hier wird nichts abgeschnitten, nur verglichen, und die Grenzen
|
|
# schadeten: "CDU-Desaster in Sachsen-Anhalt:" hat drei Woerter,
|
|
# "CDU-Desaster bei Wahl in Sachsen-Anhalt:" fuenf, und der Wechsel zwischen
|
|
# beiden faende sonst nicht statt.
|
|
_KOPF = re.compile(r"^\s*([^:]{2,60}):\s+\S")
|
|
|
|
|
|
def teilen(titel):
|
|
"""(kopf, rest), beide normalisiert. Ohne Doppelpunkt ist kopf None."""
|
|
treffer = _KOPF.match(titel or "")
|
|
if not treffer:
|
|
return None, _norm(titel)
|
|
return _norm(treffer.group(1)), _norm(titel[treffer.end(1) + 1:])
|
|
|
|
|
|
def kopf(titel):
|
|
return teilen(titel)[0]
|
|
|
|
|
|
def _norm(text):
|
|
return normalisierung.normalisiere(text, pluskasten=False)
|
|
|
|
|
|
def ticker_markiert(*texte):
|
|
return any(_TICKER.search(t) for t in texte if t)
|
|
|
|
|
|
def zahlen(text):
|
|
"""Zahlen als Multimenge. '54,4 Prozent' -> {'54': 1, '4': 1}."""
|
|
return collections.Counter(_ZAHL.findall(text or ""))
|
|
|
|
|
|
def wortdiff(alt, neu):
|
|
"""(hinzugekommene, gestrichene) Woerter, in Reihenfolge des Vorkommens."""
|
|
a, b = _norm(alt).split(), _norm(neu).split()
|
|
hinzu, entfernt = [], []
|
|
for marke, i1, i2, j1, j2 in difflib.SequenceMatcher(None, a, b).get_opcodes():
|
|
if marke in ("delete", "replace"):
|
|
entfernt.extend(a[i1:i2])
|
|
if marke in ("insert", "replace"):
|
|
hinzu.extend(b[j1:j2])
|
|
return hinzu, entfernt
|
|
|
|
|
|
def aehnlichkeit(alt, neu):
|
|
"""Wortweise Uebereinstimmung zweier Fassungen, 0 bis 1."""
|
|
a, b = _norm(alt).split(), _norm(neu).split()
|
|
if not a and not b:
|
|
return 1.0
|
|
return difflib.SequenceMatcher(None, a, b).ratio()
|
|
|
|
|
|
def klassifiziere(alt, neu):
|
|
"""Art der Aenderung zwischen zwei Fassungen.
|
|
|
|
Die Reihenfolge der Pruefungen ist die Aussage: das Engste zuerst, damit
|
|
eine Korrektur nicht als Umformulierung durchgeht.
|
|
|
|
unveraendert nichts geaendert
|
|
formal nur Zeichensetzung, Anfuehrungszeichen, Schreibung
|
|
tippfehler ein Wort korrigiert
|
|
kopfwechsel nur der Teil vor dem Doppelpunkt
|
|
erweiterung nur ergaenzt - meist eine neue Tatsache
|
|
kuerzung nur gestrichen
|
|
umformulierung beides, aber die Aussage steht noch
|
|
neufassung die Schlagzeile ist ausgetauscht
|
|
|
|
'kopfwechsel' ist eine Aussage ueber den Satzbau, nicht ueber das
|
|
Gewicht: bei "Fussball-Bundesliga: X" -> "X" wechselt eine Rubrik, bei
|
|
"Habeck: X" -> "Scholz: X" der Sprecher. Welcher Fall vorliegt, steht in
|
|
`hinzu` und `entfernt`.
|
|
|
|
Eine neunte Einstufung vergibt nur nutzlast(): 'zurueckgenommen', wenn
|
|
zwischendurch geaendert wurde und am Ende die erste Fassung wieder
|
|
dasteht. Zwischen zwei Fassungen ist sie nicht zu sehen.
|
|
"""
|
|
if alt == neu:
|
|
return "unveraendert"
|
|
|
|
na, nn = _norm(alt), _norm(neu)
|
|
if na == nn:
|
|
return "formal"
|
|
if not na or not nn:
|
|
# Kein vergleichbarer Wortbestand. Kommt vor, wenn eine Fassung nur
|
|
# aus Satzzeichen besteht; lieber offen lassen als raten.
|
|
return "unbestimmt"
|
|
|
|
wa, wb = na.split(), nn.split()
|
|
if len(wa) == len(wb):
|
|
abweichend = [(x, y) for x, y in zip(wa, wb) if x != y]
|
|
if len(abweichend) == 1:
|
|
x, y = abweichend[0]
|
|
if (abs(len(x) - len(y)) <= TIPPFEHLER_LAENGENDELTA
|
|
and difflib.SequenceMatcher(None, x, y).ratio() >= TIPPFEHLER_RATIO):
|
|
return "tippfehler"
|
|
|
|
kopf_a, rest_a = teilen(alt)
|
|
kopf_b, rest_b = teilen(neu)
|
|
if rest_a == rest_b and kopf_a != kopf_b:
|
|
return "kopfwechsel"
|
|
|
|
vergleich = difflib.SequenceMatcher(None, wa, wb)
|
|
hinzu = entfernt = 0
|
|
for marke, i1, i2, j1, j2 in vergleich.get_opcodes():
|
|
if marke in ("delete", "replace"):
|
|
entfernt += i2 - i1
|
|
if marke in ("insert", "replace"):
|
|
hinzu += j2 - j1
|
|
if hinzu and not entfernt:
|
|
return "erweiterung"
|
|
if entfernt and not hinzu:
|
|
return "kuerzung"
|
|
return "umformulierung" if vergleich.ratio() >= AEHNLICHKEIT_MIN else "neufassung"
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Laden
|
|
# --------------------------------------------------------------------------
|
|
class Fassung:
|
|
__slots__ = ("raw_item_id", "quelle", "gesehen_am", "slot", "titel", "teaser", "url")
|
|
|
|
def __init__(self, raw_item_id, quelle, gesehen_am, slot, titel, teaser, url):
|
|
self.raw_item_id = raw_item_id
|
|
self.quelle = quelle
|
|
self.gesehen_am = gesehen_am
|
|
self.slot = slot
|
|
self.titel = titel or ""
|
|
self.teaser = teaser or ""
|
|
self.url = url or ""
|
|
|
|
|
|
_SPALTEN = """
|
|
select v.raw_item_id, r.quelle, v.gesehen_am, v.slot, v.titel, v.teaser, v.url
|
|
from raw_item_versionen v
|
|
join raw_items r on r.id = v.raw_item_id
|
|
"""
|
|
|
|
# gesehen_am, id: die Fassungen eines Items sind eine Folge, und ihre
|
|
# Reihenfolge darf nicht davon abhaengen, wie die Datenbank die Zeilen
|
|
# ausliefert. Die id entscheidet, falls zwei Fassungen dieselbe Uhrzeit
|
|
# tragen - im Bestand kommt das nicht vor, verlassen wollen wir uns nicht.
|
|
_ORDNUNG = " order by v.raw_item_id, v.gesehen_am, v.id"
|
|
|
|
|
|
def lade(conn, slot=None):
|
|
"""Fassungen je Item, in zeitlicher Folge.
|
|
|
|
Ohne slot der Gesamtbestand. Mit slot alle Fassungen der Items, von denen
|
|
in diesem Slot eine neue Fassung eingetroffen ist - die *aelteren*
|
|
Fassungen gehoeren dazu, sonst laesst sich nicht sagen, was sich geaendert
|
|
hat.
|
|
"""
|
|
if slot is None:
|
|
zeilen = conn.execute(_SPALTEN + _ORDNUNG).fetchall()
|
|
else:
|
|
zeilen = conn.execute(
|
|
_SPALTEN + """ where v.raw_item_id in (
|
|
select raw_item_id from raw_item_versionen where slot = %s)"""
|
|
+ _ORDNUNG, (slot,)).fetchall()
|
|
|
|
nach_item = {}
|
|
for z in zeilen:
|
|
nach_item.setdefault(z[0], []).append(Fassung(*z))
|
|
return nach_item
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Auswerten
|
|
# --------------------------------------------------------------------------
|
|
def nutzlast(fassungen):
|
|
"""Befund zu einem Item.
|
|
|
|
Zwei Blickwinkel, weil sie verschiedene Fragen beantworten: `art` und
|
|
`aehnlichkeit` vergleichen die *erste mit der letzten* Fassung - was ist
|
|
aus der Schlagzeile geworden. `arten` haelt jeden einzelnen Schritt fest -
|
|
auf welchem Weg.
|
|
"""
|
|
erst, letzt = fassungen[0], fassungen[-1]
|
|
uebergaenge = list(zip(fassungen, fassungen[1:]))
|
|
|
|
arten = [klassifiziere(a.titel, b.titel) for a, b in uebergaenge
|
|
if a.titel != b.titel]
|
|
gesamt = klassifiziere(erst.titel, letzt.titel)
|
|
if arten and gesamt == "unveraendert":
|
|
# Die Schlagzeile wurde geaendert und wieder zurueckgedreht. Als
|
|
# "unveraendert" waere das schlicht falsch: es ist der einzige Fall,
|
|
# in dem ein Haus seine eigene Aenderung verwirft, und damit der
|
|
# interessanteste.
|
|
gesamt = "zurueckgenommen"
|
|
|
|
hinzu, entfernt = wortdiff(erst.titel, letzt.titel) if arten else ([], [])
|
|
koepfe = {teilen(f.titel)[0] for f in fassungen}
|
|
|
|
teaser_arten = [klassifiziere(a.teaser, b.teaser) for a, b in uebergaenge
|
|
if a.teaser != b.teaser]
|
|
|
|
return {
|
|
"fassungen": len(fassungen),
|
|
"titel_aenderungen": len(arten),
|
|
"teaser_aenderungen": len(teaser_arten),
|
|
"url_aenderungen": sum(1 for a, b in uebergaenge if a.url != b.url),
|
|
"art": gesamt,
|
|
"arten": arten,
|
|
"teaser_art": klassifiziere(erst.teaser, letzt.teaser) if teaser_arten else None,
|
|
"aehnlichkeit": round(aehnlichkeit(erst.titel, letzt.titel), 4),
|
|
"hinzu": hinzu[:WOERTER_MAX],
|
|
"entfernt": entfernt[:WOERTER_MAX],
|
|
"gekappt": len(hinzu) > WOERTER_MAX or len(entfernt) > WOERTER_MAX,
|
|
"woerter_erst": len(_norm(erst.titel).split()),
|
|
"woerter_letzt": len(_norm(letzt.titel).split()),
|
|
"zahlen_geaendert": zahlen(erst.titel) != zahlen(letzt.titel),
|
|
# Blieb die Rubrik ueber alle Fassungen dieselbe? Zusammen mit
|
|
# `art` = neufassung ist das die Bauform einer Fortschreibung:
|
|
# feste Rubrik, wechselnder Stand.
|
|
"kopf_stabil": len(koepfe) == 1,
|
|
"ticker_markiert": ticker_markiert(*(f.titel for f in fassungen)),
|
|
"erstmals": erst.gesehen_am.isoformat(),
|
|
"spanne_min": int((letzt.gesehen_am - erst.gesehen_am).total_seconds() // 60),
|
|
}
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Schreiben
|
|
# --------------------------------------------------------------------------
|
|
def schreibe(conn, nach_item, coder_version, trocken=False):
|
|
"""Codings ablegen, aber nur wo sich etwas geaendert hat.
|
|
|
|
Jedes verarbeitete Item bekommt eine Zeile, auch ein nie geaendertes:
|
|
sonst liesse sich "nicht umgeschrieben" nicht von "nicht verarbeitet"
|
|
unterscheiden.
|
|
|
|
konfidenz bleibt leer. Die Einstufung ist regelbasiert und trifft zu oder
|
|
nicht; eine Zahl daneben behauptete eine Wahrscheinlichkeit, die das
|
|
Verfahren nicht kennt. Das Mass der Aenderung steht als `aehnlichkeit` in
|
|
der Nutzlast, wo es hingehoert.
|
|
"""
|
|
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))
|
|
}
|
|
|
|
neu = geaendert = unveraendert = 0
|
|
stapel = []
|
|
for item_id, fassungen in nach_item.items():
|
|
last = nutzlast(fassungen)
|
|
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, Jsonb(last)))
|
|
|
|
if stapel and not trocken:
|
|
with conn.cursor() as cur:
|
|
cur.executemany(
|
|
"""insert into codings (raw_item_id, coder_version, ebene, nutzlast)
|
|
values (%s, %s, %s, %s)
|
|
on conflict (raw_item_id, coder_version, ebene)
|
|
do update set nutzlast = excluded.nutzlast,
|
|
kodiert_am = now()""",
|
|
stapel)
|
|
return {"neu": neu, "geaendert": geaendert, "unveraendert": unveraendert}
|
|
|
|
|
|
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(nach_item):
|
|
lasten = {i: nutzlast(f) for i, f in nach_item.items()}
|
|
geaendert = {i: l for i, l in lasten.items() if l["titel_aenderungen"]}
|
|
ticker = {i for i, l in geaendert.items() if l["ticker_markiert"]}
|
|
fortschreibung = {i for i, l in geaendert.items()
|
|
if l["kopf_stabil"] and l["art"] == "neufassung"}
|
|
|
|
print(f" Items: {len(lasten)}")
|
|
print(f" mit Titelaenderung: {len(geaendert)}"
|
|
f" ({sum(l['titel_aenderungen'] for l in geaendert.values())} Uebergaenge)")
|
|
print(f" nur Teaser geaendert: "
|
|
f"{sum(1 for i, l in lasten.items() if l['teaser_aenderungen'] and i not in geaendert)}")
|
|
print(f" davon Ticker/Blog markiert: {len(ticker)}")
|
|
print(f" Bauform Fortschreibung: {len(fortschreibung)}"
|
|
f" (feste Rubrik, ausgetauschter Stand)")
|
|
|
|
zaehler = collections.Counter(l["art"] for l in geaendert.values())
|
|
ohne = collections.Counter(l["art"] for i, l in geaendert.items()
|
|
if i not in ticker and i not in fortschreibung)
|
|
print("\n Art der Aenderung (erste gegen letzte Fassung):")
|
|
print(f" {'':<16}{'gesamt':>8}{'ohne Fortschreibung':>22}")
|
|
for art, n in zaehler.most_common():
|
|
print(f" {art:<16}{n:>8}{ohne.get(art, 0):>22}")
|
|
|
|
haeuser = collections.Counter(f[0].quelle for i, f in nach_item.items()
|
|
if i in geaendert and i not in ticker
|
|
and i not in fortschreibung)
|
|
if haeuser:
|
|
print("\n Umgeschriebene Schlagzeilen je Haus (ohne Fortschreibung):")
|
|
for q, n in haeuser.most_common():
|
|
print(f" {q:<16}{n:>4}")
|
|
|
|
|
|
def stichprobe(nach_item, anzahl, pfad):
|
|
"""Fassungspaare zum Nachpruefen von Hand ausschreiben.
|
|
|
|
Die Einstufung ist eine Behauptung ueber Sprache, und die kann nur ein
|
|
Mensch pruefen. Nach Art gruppiert und je Art nach Aehnlichkeit sortiert,
|
|
damit die Grenzfaelle einer Kategorie beieinander stehen.
|
|
"""
|
|
zeilen = []
|
|
for fassungen in nach_item.values():
|
|
for a, b in zip(fassungen, fassungen[1:]):
|
|
if a.titel == b.titel:
|
|
continue
|
|
zeilen.append((klassifiziere(a.titel, b.titel),
|
|
aehnlichkeit(a.titel, b.titel), a, b))
|
|
zeilen.sort(key=lambda z: (z[0], z[1], z[2].raw_item_id))
|
|
|
|
nach_art = {}
|
|
for z in zeilen:
|
|
nach_art.setdefault(z[0], []).append(z)
|
|
|
|
# Gleichmaessig ueber die Arten ziehen: eine Stichprobe, die nur aus den
|
|
# 147 Umformulierungen besteht, prueft die seltenen Kategorien nie.
|
|
je_art = max(1, anzahl // max(1, len(nach_art)))
|
|
gewaehlt = []
|
|
for art in sorted(nach_art):
|
|
gewaehlt.extend(nach_art[art][:je_art])
|
|
for art in sorted(nach_art):
|
|
for z in nach_art[art][je_art:]:
|
|
if len(gewaehlt) >= anzahl:
|
|
break
|
|
gewaehlt.append(z)
|
|
gewaehlt.sort(key=lambda z: (z[0], z[1]))
|
|
|
|
with open(pfad, "w", encoding="utf-8") as f:
|
|
f.write("# Revisions-Stichprobe. Spalte 'urteil' von Hand fuellen:"
|
|
" j = Einstufung trifft zu, n = trifft nicht zu.\n")
|
|
f.write("# Je Art aufsteigend nach Aehnlichkeit - der erste Fall einer Art"
|
|
" ist ihr aeusserster.\n\n")
|
|
art_zuvor = None
|
|
for art, aehn, a, b in gewaehlt:
|
|
if art != art_zuvor:
|
|
f.write(f"\n### {art} ({len(nach_art[art])} Uebergaenge insgesamt)\n\n")
|
|
art_zuvor = art
|
|
f.write(f"urteil=_ art={art} aehnlichkeit={aehn:.3f} "
|
|
f"item={a.raw_item_id} [{a.quelle}]\n")
|
|
f.write(f" - {a.titel}\n")
|
|
f.write(f" + {b.titel}\n\n")
|
|
return len(gewaehlt), 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("--trocken", action="store_true", help="rechnen, nichts schreiben")
|
|
p.add_argument("--stichprobe", type=int, metavar="N",
|
|
help="N Uebergaenge zum Nachpruefen ausschreiben")
|
|
p.add_argument("--stichprobe-datei", default="stichprobe-revisionen.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()
|
|
|
|
with verbindung() as conn:
|
|
v = versionsmodul.eintragen(
|
|
conn, komponenten=versionsmodul.komponenten(revision=EINSTELLUNGEN))
|
|
print(f"coder_version {v}{' (trocken)' if a.trocken else ''}")
|
|
|
|
nach_item = lade(conn, slot=slot)
|
|
if not nach_item:
|
|
print(" nichts zu tun")
|
|
return 0
|
|
bericht(nach_item)
|
|
|
|
zahlen_ = schreibe(conn, nach_item, v, trocken=a.trocken)
|
|
dauer = int((time.monotonic() - begonnen) * 1000)
|
|
print(f"\n codings: neu {zahlen_['neu']},"
|
|
f" geaendert {zahlen_['geaendert']},"
|
|
f" unveraendert {zahlen_['unveraendert']}")
|
|
print(f" Dauer: {dauer} ms")
|
|
|
|
if a.stichprobe:
|
|
n, gesamt = stichprobe(nach_item, a.stichprobe, a.stichprobe_datei)
|
|
print(f" Stichprobe: {n} von {gesamt} Uebergaengen"
|
|
f" nach {a.stichprobe_datei}")
|
|
|
|
if not a.trocken:
|
|
protokolliere(conn, slot, v, dauer, len(nach_item),
|
|
zahlen_["neu"] + zahlen_["geaendert"])
|
|
conn.commit()
|
|
else:
|
|
conn.rollback()
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|