diff --git a/scripts/bootstrap_umlaut_repair.py b/scripts/bootstrap_umlaut_repair.py new file mode 100644 index 0000000..9518553 --- /dev/null +++ b/scripts/bootstrap_umlaut_repair.py @@ -0,0 +1,78 @@ +"""Einmal-Repair: normalisiert Umlaute in summary und latest_developments +aller aktiven Lagen deterministisch (deutsche Umschreibungs-Form -> echte Umlaute). + +Idempotent: mehrfaches Ausfuehren hat keinen zusaetzlichen Effekt, wenn +bereits normalisierte Texte vorliegen. + +Aufruf (auf dem Monitor-Server): + cd /home/claude-dev/AegisSight-Monitor/src + python3 ../scripts/bootstrap_umlaut_repair.py +""" +import sqlite3 +import sys +import os + +# Sicherstellen, dass src/ im PYTHONPATH ist, damit services/post_refresh_qc importiert werden kann +_here = os.path.dirname(os.path.abspath(__file__)) +_src = os.path.abspath(os.path.join(_here, "..", "src")) +if _src not in sys.path: + sys.path.insert(0, _src) + +from services.post_refresh_qc import normalize_german_umlauts # noqa: E402 + +DB_PATH = "/home/claude-dev/osint-data/osint.db" + + +def main(): + conn = sqlite3.connect(DB_PATH) + conn.row_factory = sqlite3.Row + try: + c = conn.cursor() + rows = c.execute( + "SELECT id, title, summary, latest_developments FROM incidents " + "WHERE status IN ('active', 'archived') ORDER BY id" + ).fetchall() + + total_summary = 0 + total_dev = 0 + updated = 0 + + for r in rows: + iid = r["id"] + title = r["title"] or "" + summary_orig = r["summary"] or "" + dev_orig = r["latest_developments"] or "" + + new_summary, n_s = normalize_german_umlauts(summary_orig) + new_dev, n_d = normalize_german_umlauts(dev_orig) + + if n_s == 0 and n_d == 0: + continue + + c.execute( + "UPDATE incidents SET summary = ?, latest_developments = ? WHERE id = ?", + ( + new_summary if n_s > 0 else summary_orig, + new_dev if n_d > 0 else dev_orig, + iid, + ), + ) + updated += 1 + total_summary += n_s + total_dev += n_d + print( + f" Lage #{iid:>3} {title[:50]:50} " + f"summary: {n_s:>4} | latest_developments: {n_d:>3}" + ) + + conn.commit() + print() + print(f"Ergebnis: {updated} Lagen aktualisiert. " + f"{total_summary} Ersetzungen in summary, {total_dev} in latest_developments " + f"(gesamt {total_summary + total_dev}).") + finally: + conn.close() + + +if __name__ == "__main__": + main() diff --git a/src/agents/analyzer.py b/src/agents/analyzer.py index e961426..ddc8fee 100644 --- a/src/agents/analyzer.py +++ b/src/agents/analyzer.py @@ -140,6 +140,7 @@ REGELN: - Neutral und sachlich - keine Wertungen oder Spekulationen - KEINE Gedankenstriche (—, –) verwenden — stattdessen Kommas, Doppelpunkte oder neue Saetze - Bei widersprüchlichen Angaben beide Seiten erwähnen +- Falls das BISHERIGE LAGEBILD Umschreibungen enthält (ae, oe, ue, ss anstelle von ä, ö, ü, ß), ersetze diese beim Aktualisieren durch echte Umlaute. Die Regel "echte UTF-8-Umlaute" hat Vorrang vor der Regel "bestehende Formulierungen beibehalten". - Wenn eine Quelle eine erkennbare Ausrichtung hat (z.B. pro-russisch, pro-iranisch, staatsnah, rechtsextrem), muss dies im Fliesstext erwaehnt werden, damit der Leser die Information einordnen kann. Beispiel: "Laut dem pro-russischen Telegram-Kanal Rybar..." oder "Die iranische Nachrichtenagentur Fars meldete..." oder "Der rechtsextreme Kanal Compact behauptete..." - Quellen immer mit [Nr] referenzieren - Ältere Quellen zeitlich einordnen @@ -189,6 +190,7 @@ WICHTIG zur Sektion ZUSAMMENFASSUNG: REGELN: - Bisherige gesicherte Fakten beibehalten - KEINE Gedankenstriche (—, –) verwenden — stattdessen Kommas, Doppelpunkte oder neue Saetze +- Falls das bisherige Briefing Umschreibungen enthält (ae, oe, ue, ss anstelle von ä, ö, ü, ß), ersetze diese beim Aktualisieren durch echte Umlaute. Die Regel "echte UTF-8-Umlaute" hat Vorrang vor der Regel "bestehende Formulierungen beibehalten". - Neue Erkenntnisse einarbeiten - Veraltete Informationen aktualisieren - Wenn eine Quelle eine erkennbare Ausrichtung hat (z.B. pro-russisch, pro-iranisch, staatsnah, rechtsextrem), muss dies im Fliesstext erwaehnt werden, damit der Leser die Information einordnen kann. Beispiel: "Laut dem pro-russischen Telegram-Kanal Rybar..." oder "Die iranische Nachrichtenagentur Fars meldete..." oder "Der rechtsextreme Kanal Compact behauptete..." diff --git a/src/services/post_refresh_qc.py b/src/services/post_refresh_qc.py index 58032d4..e21d8e8 100644 --- a/src/services/post_refresh_qc.py +++ b/src/services/post_refresh_qc.py @@ -3,6 +3,7 @@ Prueft nach jedem Refresh: 1. Semantische Faktencheck-Duplikate (Haiku-Clustering mit Fuzzy-Vorfilter) 2. Falsch kategorisierte Karten-Locations (Haiku bewertet Kontext der Lage) +3. Umlaut-Normalisierung in summary + latest_developments (deterministisch) Regelbasierte Listen dienen als Fallback falls Haiku fehlschlaegt. """ @@ -397,19 +398,281 @@ async def run_post_refresh_qc(db, incident_id: int) -> dict: locations_fixed = await check_location_categories( db, incident_id, incident_title, incident_desc ) + umlauts_fixed = await normalize_umlaut_fields(db, incident_id) - if facts_removed > 0 or locations_fixed > 0: + if facts_removed > 0 or locations_fixed > 0 or umlauts_fixed > 0: await db.commit() logger.info( - "Post-Refresh QC fuer Incident %d: %d Duplikate entfernt, %d Locations korrigiert", - incident_id, facts_removed, locations_fixed, + "Post-Refresh QC fuer Incident %d: %d Duplikate entfernt, %d Locations korrigiert, %d Umlaute normalisiert", + incident_id, facts_removed, locations_fixed, umlauts_fixed, ) - return {"facts_removed": facts_removed, "locations_fixed": locations_fixed} + return { + "facts_removed": facts_removed, + "locations_fixed": locations_fixed, + "umlauts_fixed": umlauts_fixed, + } except Exception as e: logger.error( "Post-Refresh QC Fehler fuer Incident %d: %s", incident_id, e, exc_info=True, ) - return {"facts_removed": 0, "locations_fixed": 0, "error": str(e)} + return {"facts_removed": 0, "locations_fixed": 0, "umlauts_fixed": 0, "error": str(e)} + + +# --------------------------------------------------------------------------- +# 3. Umlaut-Normalisierung (deterministisch, Sicherheitsnetz gegen LLM-Drift) +# --------------------------------------------------------------------------- + +# Basis-Woerterbuch: Umschreibungsform (lowercase) -> Umlaut-Form. +# Die Capitalize-Variante wird automatisch generiert (z. B. "Oeffnung" -> "Öffnung"). +_UMLAUT_BASE = { + # Oeffnung / Schliessung / Strasse / Schuesse (Topic-spezifisch aus Lage #6) + "oeffnung": "öffnung", "oeffnungen": "öffnungen", + "oeffne": "öffne", "oeffnen": "öffnen", "oeffnet": "öffnet", + "geoeffnet": "geöffnet", "geoeffnete": "geöffnete", "geoeffneten": "geöffneten", + "oeffentlich": "öffentlich", "oeffentlichkeit": "öffentlichkeit", + "schliessung": "schließung", "schliessen": "schließen", "schliesst": "schließt", + "schliesse": "schließe", "schliesslich": "schließlich", + "strasse": "straße", "strassen": "straßen", + "schuss": "schuss", "schuesse": "schüsse", "schuessen": "schüssen", + "beschuss": "beschuss", + # Hilfsverben + haeufige Woerter + "fuer": "für", "fuers": "fürs", + "ueber": "über", "ueberall": "überall", "ueberfall": "überfall", + "moeglich": "möglich", "moegliche": "mögliche", "moeglicher": "möglicher", + "moegliches": "mögliches", "moeglichen": "möglichen", + "moeglichkeit": "möglichkeit", "moeglichkeiten": "möglichkeiten", + "koennen": "können", "koennte": "könnte", "koennten": "könnten", + "koenne": "könne", "koennt": "könnt", + "muessen": "müssen", "muesse": "müsse", "muessten": "müssten", + "muesste": "müsste", "muesst": "müsst", + "duerfen": "dürfen", "duerfte": "dürfte", "duerften": "dürften", + # fuehr*, waehr*, loes*, erklaer* + "fuehren": "führen", "fuehrt": "führt", "fuehrte": "führte", "gefuehrt": "geführt", + "fuehrung": "führung", "fuehrungen": "führungen", "fuehrer": "führer", + "ausfuehren": "ausführen", "ausgefuehrt": "ausgeführt", "ausfuehrlich": "ausführlich", + "einfuehrung": "einführung", "einfuehren": "einführen", + "waehrend": "während", "waehrung": "währung", "waehrungen": "währungen", + "gewaehren": "gewähren", "gewaehrt": "gewährt", "gewaehrleisten": "gewährleisten", + "erwaehnt": "erwähnt", "erwaehnung": "erwähnung", "erwaehnen": "erwähnen", + "loesen": "lösen", "loest": "löst", "geloest": "gelöst", + "loesung": "lösung", "loesungen": "lösungen", + "loeschen": "löschen", "geloescht": "gelöscht", + "erklaeren": "erklären", "erklaert": "erklärt", "erklaerte": "erklärte", + "erklaerten": "erklärten", "erklaertes": "erklärtes", "erklaertem": "erklärtem", + "erklaerung": "erklärung", "erklaerungen": "erklärungen", + "verspaetet": "verspätet", "verspaetung": "verspätung", "verspaetungen": "verspätungen", + "veroeffentlichen": "veröffentlichen", "veroeffentlicht": "veröffentlicht", + "veroeffentlichte": "veröffentlichte", "veroeffentlichten": "veröffentlichten", + "veroeffentlichung": "veröffentlichung", "veroeffentlichungen": "veröffentlichungen", + # Auslaender, Verstaendnis, Bevoelkerung + "auslaender": "ausländer", "auslaendisch": "ausländisch", + "auslaendische": "ausländische", "auslaendischen": "ausländischen", + "verstaendnis": "verständnis", "verstaendigung": "verständigung", + "verstaendlich": "verständlich", "verstaendlicher": "verständlicher", + "bevoelkerung": "bevölkerung", "bevoelkerungen": "bevölkerungen", + # Aussen, Aeusser, Kuenftig, Grundsaetzlich + "aussen": "außen", "aussenminister": "außenminister", + "aussenministerin": "außenministerin", "aussenpolitik": "außenpolitik", + "aussenpolitisch": "außenpolitisch", "aussenpolitische": "außenpolitische", + "aeusserung": "äußerung", "aeusserungen": "äußerungen", + "aeussern": "äußern", "aeussert": "äußert", "aeusserte": "äußerte", + "aeusserst": "äußerst", "aeussere": "äußere", "aeusseren": "äußeren", + "kuenftig": "künftig", "kuenftige": "künftige", "kuenftigen": "künftigen", + "grundsaetzlich": "grundsätzlich", "grundsaetze": "grundsätze", "grundsatz": "grundsatz", + # Maenner, Aerzte, Aendern, Entsteh* + "maenner": "männer", "maennlich": "männlich", "maennliche": "männliche", + "aerzte": "ärzte", "aerztlich": "ärztlich", "aerztliche": "ärztliche", + "aendern": "ändern", "aenderung": "änderung", "aenderungen": "änderungen", + "geaendert": "geändert", "unveraendert": "unverändert", + "verstaerken": "verstärken", "verstaerkt": "verstärkt", "verstaerkung": "verstärkung", + # Gruend*, Rueck*, Zurueck* + "gruenden": "gründen", "gruendung": "gründung", "gegruendet": "gegründet", + "gruende": "gründe", "begruendung": "begründung", "begruendet": "begründet", + "rueckkehr": "rückkehr", "rueckzug": "rückzug", "ruecken": "rücken", + "rueckseite": "rückseite", "rueckfall": "rückfall", + "zuruecknahme": "zurücknahme", "zurueck": "zurück", "zuruecknehmen": "zurücknehmen", + "zurueckgezogen": "zurückgezogen", "zurueckgekehrt": "zurückgekehrt", + # Fluege, Behoerden, Buerg* + "fluege": "flüge", "fluegel": "flügel", + "ruestung": "rüstung", "ruestungen": "rüstungen", "ruestungsexport": "rüstungsexport", + "aufruestung": "aufrüstung", "abruestung": "abrüstung", + "anschliessen": "anschließen", "anschliesst": "anschließt", + "angeschlossen": "angeschlossen", + "entschliessen": "entschließen", "entschliesst": "entschließt", + "entschloss": "entschloss", "entschlossen": "entschlossen", + "entschliessung": "entschließung", + "beschliessen": "beschließen", "beschliesst": "beschließt", + "beschloss": "beschloss", "beschlossen": "beschlossen", "beschluss": "beschluss", + "ausschliessen": "ausschließen", "ausschliesst": "ausschließt", + "ausgeschlossen": "ausgeschlossen", "ausschluss": "ausschluss", + "erfuellen": "erfüllen", "erfuellt": "erfüllt", "erfuellung": "erfüllung", + "durchfuehren": "durchführen", "durchgefuehrt": "durchgeführt", + "durchfuehrung": "durchführung", + "zurueckfuehren": "zurückführen", "zurueckgefuehrt": "zurückgeführt", + "zurueckziehen": "zurückziehen", "zurueckgezogen": "zurückgezogen", + "zurueckgewiesen": "zurückgewiesen", + "behoerde": "behörde", "behoerden": "behörden", "behoerdlich": "behördlich", + "buerger": "bürger", "buergerlich": "bürgerlich", "buergermeister": "bürgermeister", + "buergerrechte": "bürgerrechte", + "tuerkei": "türkei", "tuerkisch": "türkisch", "tuerkische": "türkische", + "tuerkischen": "türkischen", "tuerke": "türke", "tuerken": "türken", + # Staedtenamen (Spezialfaelle) + "koeln": "köln", "koelner": "kölner", + "muenchen": "münchen", "muenchner": "münchner", + # Thema aktueller Lage: Praesident, Druecker, etc. + "praesident": "präsident", "praesidentin": "präsidentin", + "praesidenten": "präsidenten", "praesidentschaft": "präsidentschaft", + # Suedeutsch -> Sueddeutsch (Doppel-D-Spezialfall, daher NICHT in der Form ss/oe) + # NOTE: "Suedeutsch" ist schon die Umschreibung; korrekt ist "Sueddeutsch" + # dann weiter zu "Süddeutsch". Wir loesen nur die ue->ü-Wandlung: + "suedeutsch": "süddeutsch", "suedeutsche": "süddeutsche", + "suedeutschen": "süddeutschen", + "sueddeutsch": "süddeutsch", "sueddeutsche": "süddeutsche", + "sueddeutschen": "süddeutschen", + "sued": "süd", "sueden": "süden", "suedlich": "südlich", + "suedost": "südost", "suedwest": "südwest", + "suedkorea": "südkorea", "suedkoreas": "südkoreas", "suedkoreanisch": "südkoreanisch", + # Bestaetigen, Gespraech, Toetung, Sekretaer, Franzoesisch + "bestaetigen": "bestätigen", "bestaetigt": "bestätigt", + "bestaetigte": "bestätigte", "bestaetigten": "bestätigten", + "bestaetigung": "bestätigung", "bestaetigungen": "bestätigungen", + "gespraech": "gespräch", "gespraeche": "gespräche", + "gespraechen": "gesprächen", "gespraechs": "gesprächs", + "toetung": "tötung", "toetungen": "tötungen", + "toeten": "töten", "toetet": "tötet", "getoetet": "getötet", + "sekretaer": "sekretär", "sekretaerin": "sekretärin", + "generalsekretaer": "generalsekretär", "generalsekretaerin": "generalsekretärin", + "franzoesisch": "französisch", "franzoesische": "französische", + "franzoesischen": "französischen", "franzoesischer": "französischer", + "franzoesisches": "französisches", + # Sanitaeter, Stationaer, Militaer, Maerz, Vollstaendig + "sanitaet": "sanität", "sanitaeter": "sanitäter", + "stationaer": "stationär", "stationaere": "stationäre", "stationaeren": "stationären", + "militaer": "militär", "militaerisch": "militärisch", + "militaerische": "militärische", "militaerischen": "militärischen", + "militaerkommando": "militärkommando", "militaerbasis": "militärbasis", + "militaerschlag": "militärschlag", "militaerschlaege": "militärschläge", + "maerz": "märz", + "vollstaendig": "vollständig", "vollstaendige": "vollständige", + "vollstaendigen": "vollständigen", "vollstaendigkeit": "vollständigkeit", + # Gegenueber, Abhaengig, Zehntaegig, Geloest, Enthuellt, Wuerden, Weiss + "gegenueber": "gegenüber", + "abhaengig": "abhängig", "abhaengige": "abhängige", "abhaengigen": "abhängigen", + "abhaengigkeit": "abhängigkeit", "unabhaengig": "unabhängig", + "taeglich": "täglich", "taegliche": "tägliche", "taeglichen": "täglichen", + "taegig": "tägig", "zehntaegig": "zehntägig", "zehntaegige": "zehntägige", + "geloest": "gelöst", "geloeste": "gelöste", "geloesten": "gelösten", + "enthuellen": "enthüllen", "enthuellt": "enthüllt", + "enthuellte": "enthüllte", "enthuellung": "enthüllung", + "wuerde": "würde", "wuerden": "würden", "wuerdig": "würdig", + "wuerdigung": "würdigung", + "weiss": "weiß", "weisse": "weiße", "weissen": "weißen", "weisses": "weißes", + # Vize- und Parlamentspraesident, Ankuendigung, Knuepfen + "vizepraesident": "vizepräsident", "vizepraesidentin": "vizepräsidentin", + "vizepraesidenten": "vizepräsidenten", + "parlamentspraesident": "parlamentspräsident", + "ankuendigung": "ankündigung", "ankuendigungen": "ankündigungen", + "ankuendigen": "ankündigen", "angekuendigt": "angekündigt", + "knuepfen": "knüpfen", "knuepft": "knüpft", "geknuepft": "geknüpft", + "anknuepfen": "anknüpfen", + # Komposita mit Strasse, Oeffnung + "wasserstrasse": "wasserstraße", "wasserstrassen": "wasserstraßen", + "autostrasse": "autostraße", "hauptstrasse": "hauptstraße", + # Weitere zusammengesetzte Formen + "zugefuehrt": "zugeführt", "ueberfuehrt": "überführt", + "hergefuehrt": "hergeführt", "hingefuehrt": "hingeführt", +} + + +def _build_umlaut_map() -> dict: + """Baut die vollstaendige Ersetzungs-Map inklusive Capitalize-Varianten.""" + result = {} + for k, v in _UMLAUT_BASE.items(): + result[k] = v + # Erste-Buchstabe-gross (haeufigster Case am Satzanfang) + result[k[:1].upper() + k[1:]] = v[:1].upper() + v[1:] + return result + + +_UMLAUT_REPLACEMENTS = _build_umlaut_map() + +# Whitelist: Tokens, die trotz potenziellen Matches NIE ersetzt werden. +# Aktuell leer, weil kein Eintrag in _UMLAUT_BASE mit englischen Eigennamen kollidiert. +# Falls in Zukunft Ambiguitaeten auftreten (z. B. Nachname "Kuehn"), hier ergaenzen. +_UMLAUT_WHITELIST = frozenset() + +# Kompilierter Regex: laengste Keys zuerst (damit "aussenminister" vor "aussen" trifft) +_UMLAUT_PATTERN = re.compile( + r"\b(" + "|".join( + re.escape(k) for k in sorted(_UMLAUT_REPLACEMENTS.keys(), key=len, reverse=True) + ) + r")\b" +) + + +def normalize_german_umlauts(text: str) -> tuple[str, int]: + """Ersetzt typische deutsche Umschreibungen durch echte Umlaute. + + Deterministisch, wortgrenzen-basiert, case-preserving. Sicher gegen + englische Wortbestandteile (Boeing, Business, Access) weil nur + explizit gelistete deutsche Woerter ersetzt werden. + + Rueckgabe: (normalisierter_text, anzahl_ersetzungen) + """ + if not text: + return text, 0 + count = [0] + + def _replace(match: re.Match) -> str: + word = match.group(0) + if word in _UMLAUT_WHITELIST: + return word + replacement = _UMLAUT_REPLACEMENTS.get(word) + if replacement is None: + return word + count[0] += 1 + return replacement + + new_text = _UMLAUT_PATTERN.sub(_replace, text) + return new_text, count[0] + + +async def normalize_umlaut_fields(db, incident_id: int) -> int: + """Liest summary + latest_developments eines Incidents, normalisiert Umlaute, + schreibt bei tatsaechlichen Aenderungen zurueck. + + Rueckgabe: Anzahl der Ersetzungen insgesamt (summary + latest_developments). + """ + cursor = await db.execute( + "SELECT summary, latest_developments FROM incidents WHERE id = ?", + (incident_id,), + ) + row = await cursor.fetchone() + if not row: + return 0 + + orig_summary = row["summary"] or "" + orig_dev = row["latest_developments"] or "" + + new_summary, count_summary = normalize_german_umlauts(orig_summary) + new_dev, count_dev = normalize_german_umlauts(orig_dev) + + total = count_summary + count_dev + if total == 0: + return 0 + + await db.execute( + "UPDATE incidents SET summary = ?, latest_developments = ? WHERE id = ?", + ( + new_summary if count_summary > 0 else orig_summary, + new_dev if count_dev > 0 else orig_dev, + incident_id, + ), + ) + logger.info( + "Umlaut-Normalisierung Incident %d: %d in summary, %d in latest_developments", + incident_id, count_summary, count_dev, + ) + return total