Dateien
AegisSight-Monitor/src/services/post_refresh_qc.py
claude-dev f05bd1a064 QC: Umlaut-Dict aus hunspell-de-de generieren (statt handkuratiert)
Loest das Abdeckungs-Problem des handkuratierten Dicts (~300 Eintraege,
~95%). Neu: vollautomatisch erzeugtes Korpus-Dict aus hunspell-de-de
mit 153.869 Eintraegen (>99% Abdeckung), plus schlankes Supplement
fuer Komposita, die hunspell nicht liefert.

Build-Skript (scripts/build_umlaut_dict.py):
- ruft /usr/bin/unmunch gegen /usr/share/hunspell/de_DE.dic+aff auf
- filtert Woerter mit echten Umlauten (ä/ö/ü/ß)
- generiert je Wort die Umschreibungsform (ae/oe/ue/ss) + Capitalize
- Mehrdeutigkeits-Check: skippt Paare wo die Umschreibung selbst
  ein gueltiges deutsches Wort ist (z. B. dass/daß, Masse/Maße, Busse/Buße)
- Ergebnis: 153.869 Eintraege, 27 mehrdeutige Formen ausgefiltert
- Alphabetisch sortiertes JSON (diff-freundlich)

Laufzeit-Refactor (src/services/post_refresh_qc.py):
- _UMLAUT_BASE Dict (handkuratiert) entfernt, dafuer JSON-Loader
  beim Modul-Import aus src/services/umlaut_dict.json
- _MANUAL_SUPPLEMENT fuer Luecken (Konjunktiv saeen, Amtstitel-
  Komposita wie Aussenminister/Parlamentspraesident, Strassen-
  Komposita, Fuehrungs-Komposita) — ueberlagert Korpus-Dict
- _UMLAUT_WHITELIST erweitert um englische Fremdwoerter (Boeing,
  Business, Access, Process, Message, Password, Miss, Boss, Goethe,
  Yahoo, Israel, Israels)
- Regex-Strategie umgestellt: statt riesigem alternierenden Pattern
  ueber alle Keys jetzt Tokenizer (_WORD_PATTERN) + O(1) Dict-Lookup
  pro Wort. Deutlich performanter bei 150k+ Eintraegen.
- normalize_german_umlauts() Signatur unveraendert
- normalize_umlaut_fields() unveraendert
- Einhaengung in run_post_refresh_qc() unveraendert

Daten-Artefakt (src/services/umlaut_dict.json):
- 4.88 MB alphabetisch sortiertes JSON
- Im Repo committet zwecks Reproduzierbarkeit und kein hunspell-
  Laufzeit-Abhaengigkeit im Container

Verwerfbarkeit voll erhalten:
- git revert entfernt alle drei neuen Elemente
- Bestand in DB bleibt repariert (korrektes Deutsch, kein Schaden)
- hunspell-Paket kann bleiben oder mit apt purge entfernt werden

Bootstrap-Rerun mit neuem Dict:
- 7 Lagen aktualisiert, 306 zusaetzliche Ersetzungen
- Lage #6 (Irankonflikt) von 140 ursprungs- und 15 Rest-Treffern
  nach voriger Runde jetzt auf 0 Hard-Hits
- andere aktive Lagen insgesamt 8 verbleibende Rest-Treffer
  (spezielle Eigennamen, koennen bei Bedarf ins Supplement)

Performance:
- Dict-Load beim Modul-Import: ~100 ms
- Gesamt Unit-Tests (11 Faelle): 161 ms
- Refresh-Pfad unveraendert schnell: O(Wortzahl) mit Hashmap-Lookup
2026-04-18 21:17:46 +00:00

571 Zeilen
21 KiB
Python

"""Post-Refresh Quality Check via Haiku.
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.
"""
import json
import logging
import os
import re
from difflib import SequenceMatcher
from agents.claude_client import call_claude
from config import CLAUDE_MODEL_FAST
logger = logging.getLogger("osint.post_refresh_qc")
STATUS_PRIORITY = {
"confirmed": 5, "established": 5,
"contradicted": 4, "disputed": 4,
"unconfirmed": 3, "unverified": 3,
"developing": 1,
}
# ---------------------------------------------------------------------------
# 1. Faktencheck-Duplikate
# ---------------------------------------------------------------------------
_DEDUP_PROMPT = """\
Du bist ein Deduplizierungs-Agent fuer Faktenchecks eines OSINT-Monitors.
LAGE: {incident_title}
Unten stehen Faktenchecks (ID + Status + Claim). Finde Gruppen von Fakten,
die INHALTLICH DASSELBE aussagen, auch wenn sie unterschiedlich formuliert sind.
REGELN:
- Gleicher Sachverhalt = gleiche Gruppe
(z.B. "Trump fordert Kapitulation" und "US-Praesident verlangt bedingungslose Aufgabe")
- Unterschiedliche Detailtiefe zum SELBEN Fakt = gleiche Gruppe
- VERSCHIEDENE Sachverhalte = VERSCHIEDENE Gruppen
(z.B. "Angriff auf Isfahan" vs "Angriff auf Teheran" sind NICHT dasselbe)
- Eine Gruppe muss mindestens 2 Eintraege haben
Antworte NUR als JSON-Array von Gruppen. Jede Gruppe ist ein Array von IDs:
[[1,5,12], [3,8]]
Wenn keine Duplikate: antworte mit []
FAKTEN:
{facts_text}"""
async def _haiku_find_duplicate_clusters(
facts: list[dict], incident_title: str
) -> list[list[int]]:
"""Fragt Haiku welche Fakten semantische Duplikate sind."""
facts_text = "\n".join(
f'ID={f["id"]} [{f["status"]}]: {f["claim"]}'
for f in facts
)
prompt = _DEDUP_PROMPT.format(
incident_title=incident_title, facts_text=facts_text
)
try:
result, _usage = await call_claude(prompt, tools=None, model=CLAUDE_MODEL_FAST)
data = json.loads(result)
if isinstance(data, list) and all(isinstance(g, list) for g in data):
return data
except json.JSONDecodeError:
match = re.search(r'\[.*\]', result, re.DOTALL)
if match:
try:
data = json.loads(match.group())
if isinstance(data, list):
return data
except json.JSONDecodeError:
pass
except Exception as e:
logger.warning("Haiku Duplikat-Clustering fehlgeschlagen: %s", e)
return []
def _fuzzy_prefilter(all_facts: list[dict], max_candidates: int = 80) -> list[dict]:
"""Waehlt Kandidaten fuer Haiku-Check per Fuzzy-Vorfilter aus.
Findet Paare mit Aehnlichkeit >= 0.60 und gibt die betroffenen Fakten zurueck.
Begrenzt auf max_candidates um Haiku-Tokens zu sparen.
"""
from agents.factchecker import normalize_claim, _keyword_set
if len(all_facts) <= max_candidates:
return all_facts
normalized = []
for f in all_facts:
nc = normalize_claim(f["claim"])
kw = _keyword_set(f["claim"])
normalized.append((f, nc, kw))
candidate_ids = set()
recent = normalized[:60]
for i, (fact_a, norm_a, kw_a) in enumerate(recent):
for j, (fact_b, norm_b, kw_b) in enumerate(normalized):
if i >= j or fact_b["id"] == fact_a["id"]:
continue
if not norm_a or not norm_b:
continue
len_ratio = len(norm_a) / len(norm_b) if norm_b else 0
if len_ratio > 2.5 or len_ratio < 0.4:
continue
seq_ratio = SequenceMatcher(None, norm_a, norm_b).ratio()
kw_union = kw_a | kw_b
jaccard = len(kw_a & kw_b) / len(kw_union) if kw_union else 0.0
combined = 0.7 * seq_ratio + 0.3 * jaccard
if combined >= 0.60:
candidate_ids.add(fact_a["id"])
candidate_ids.add(fact_b["id"])
if len(candidate_ids) >= max_candidates:
break
if len(candidate_ids) >= max_candidates:
break
candidates = [f for f in all_facts if f["id"] in candidate_ids]
logger.info(
"Fuzzy-Vorfilter: %d/%d Fakten als Duplikat-Kandidaten identifiziert",
len(candidates), len(all_facts),
)
return candidates
async def check_fact_duplicates(db, incident_id: int, incident_title: str) -> int:
"""Prueft auf semantische Faktencheck-Duplikate via Haiku.
1. Fuzzy-Vorfilter reduziert auf relevante Kandidaten
2. Haiku clustert semantische Duplikate
3. Pro Cluster: behalte besten Fakt, loesche Rest
Returns: Anzahl entfernter Duplikate.
"""
cursor = await db.execute(
"SELECT id, claim, status, sources_count, evidence, checked_at "
"FROM fact_checks WHERE incident_id = ? ORDER BY checked_at DESC",
(incident_id,),
)
all_facts = [dict(row) for row in await cursor.fetchall()]
if len(all_facts) < 2:
return 0
# Schritt 1: Fuzzy-Vorfilter
candidates = _fuzzy_prefilter(all_facts)
if len(candidates) < 2:
return 0
# Schritt 2: Haiku-Clustering (in Batches von max 80)
all_clusters = []
batch_size = 80
for i in range(0, len(candidates), batch_size):
batch = candidates[i:i + batch_size]
clusters = await _haiku_find_duplicate_clusters(batch, incident_title)
all_clusters.extend(clusters)
if not all_clusters:
logger.info("QC Fakten: Haiku fand keine Duplikate")
return 0
# Schritt 3: Pro Cluster besten behalten, Rest loeschen
facts_by_id = {f["id"]: f for f in all_facts}
ids_to_delete = set()
for cluster_ids in all_clusters:
valid_ids = [cid for cid in cluster_ids if cid in facts_by_id]
if len(valid_ids) <= 1:
continue
cluster_facts = [facts_by_id[cid] for cid in valid_ids]
best = max(cluster_facts, key=lambda f: (
STATUS_PRIORITY.get(f["status"], 0),
f.get("sources_count", 0),
f.get("checked_at", ""),
))
for fact in cluster_facts:
if fact["id"] != best["id"]:
ids_to_delete.add(fact["id"])
logger.info(
"QC Duplikat: ID %d entfernt, behalte ID %d ('%s')",
fact["id"], best["id"], best["claim"][:60],
)
if ids_to_delete:
placeholders = ",".join("?" * len(ids_to_delete))
await db.execute(
f"DELETE FROM fact_checks WHERE id IN ({placeholders})",
list(ids_to_delete),
)
logger.info(
"QC: %d Faktencheck-Duplikate entfernt fuer Incident %d",
len(ids_to_delete), incident_id,
)
return len(ids_to_delete)
# ---------------------------------------------------------------------------
# 2. Karten-Location-Kategorien
# ---------------------------------------------------------------------------
_LOCATION_PROMPT = """\
Du bist ein Geopolitik-Experte fuer einen OSINT-Monitor.
LAGE: {incident_title}
BESCHREIBUNG: {incident_desc}
{labels_context}
Unten stehen Orte, die auf der Karte als "primary" (Hauptgeschehen) markiert sind.
Pruefe fuer jeden Ort, ob die Kategorie "primary" korrekt ist.
KATEGORIEN:
- primary: {label_primary} — Wo das Hauptgeschehen stattfindet
- secondary: {label_secondary} — Direkte Reaktionen/Gegenmassnahmen
- tertiary: {label_tertiary} — Entscheidungstraeger/Beteiligte
- mentioned: {label_mentioned} — Nur erwaehnt
REGELN:
- Nur Orte die DIREKT vom Hauptgeschehen betroffen sind = "primary"
- Orte mit Reaktionen/Gegenmassnahmen = "secondary"
- Orte von Entscheidungstraegern (z.B. Hauptstaedte) = "tertiary"
- Nur erwaehnte Orte = "mentioned"
- Im Zweifel: "mentioned"
Antworte als JSON-Array mit Korrekturen. Nur Eintraege die GEAENDERT werden muessen:
[{{"id": 123, "category": "mentioned"}}, {{"id": 456, "category": "tertiary"}}]
Wenn alle Kategorien korrekt sind: antworte mit []
ORTE (aktuell alle als "primary" markiert):
{locations_text}"""
async def check_location_categories(
db, incident_id: int, incident_title: str, incident_desc: str
) -> int:
"""Prueft Karten-Location-Kategorien via Haiku.
Returns: Anzahl korrigierter Eintraege.
"""
cursor = await db.execute(
"SELECT id, location_name, latitude, longitude, category "
"FROM article_locations WHERE incident_id = ? AND category = 'primary'",
(incident_id,),
)
targets = [dict(row) for row in await cursor.fetchall()]
if not targets:
return 0
# Category-Labels aus DB laden (fuer kontextabhaengige Prompt-Beschreibungen)
cursor = await db.execute(
"SELECT category_labels FROM incidents WHERE id = ?", (incident_id,)
)
inc_row = await cursor.fetchone()
labels = {}
if inc_row and inc_row["category_labels"]:
try:
labels = json.loads(inc_row["category_labels"])
except (json.JSONDecodeError, TypeError):
pass
label_primary = labels.get("primary") or "Hauptgeschehen"
label_secondary = labels.get("secondary") or "Reaktionen"
label_tertiary = labels.get("tertiary") or "Beteiligte"
label_mentioned = labels.get("mentioned") or "Erwaehnt"
labels_context = ""
if labels:
labels_context = f"KATEGORIE-LABELS: primary={label_primary}, secondary={label_secondary}, tertiary={label_tertiary}, mentioned={label_mentioned}\n"
# Dedupliziere nach location_name fuer den Prompt (spart Tokens)
unique_names = {}
ids_by_name = {}
for loc in targets:
name = loc["location_name"]
if name not in unique_names:
unique_names[name] = loc
ids_by_name[name] = []
ids_by_name[name].append(loc["id"])
locations_text = "\n".join(
f'ID={loc["id"]} | {loc["location_name"]} ({loc["latitude"]:.2f}, {loc["longitude"]:.2f})'
for loc in unique_names.values()
)
prompt = _LOCATION_PROMPT.format(
incident_title=incident_title,
incident_desc=incident_desc[:500] if incident_desc else "(keine Beschreibung)",
labels_context=labels_context,
label_primary=label_primary,
label_secondary=label_secondary,
label_tertiary=label_tertiary,
label_mentioned=label_mentioned,
locations_text=locations_text,
)
fixes = []
try:
result, _usage = await call_claude(prompt, tools=None, model=CLAUDE_MODEL_FAST)
data = json.loads(result)
if isinstance(data, list):
fixes = data
except json.JSONDecodeError:
match = re.search(r'\[.*\]', result, re.DOTALL)
if match:
try:
data = json.loads(match.group())
if isinstance(data, list):
fixes = data
except json.JSONDecodeError:
pass
except Exception as e:
logger.warning("Haiku Location-Check fehlgeschlagen: %s", e)
return 0
if not fixes:
logger.info("QC Locations: Haiku fand keine falschen Kategorien")
return 0
# Korrekturen anwenden (auch auf alle IDs mit gleichem Namen)
total_fixed = 0
representative_ids = {loc["id"]: name for name, loc in unique_names.items()}
for fix in fixes:
fix_id = fix.get("id")
new_cat = fix.get("category")
if not fix_id or not new_cat:
continue
if new_cat not in ("primary", "secondary", "tertiary", "mentioned"):
continue
# Finde den location_name fuer diese ID
loc_name = representative_ids.get(fix_id)
if not loc_name:
continue
# Korrigiere ALLE Eintraege mit diesem Namen
all_ids = ids_by_name.get(loc_name, [fix_id])
placeholders = ",".join("?" * len(all_ids))
await db.execute(
f"UPDATE article_locations SET category = ? "
f"WHERE id IN ({placeholders}) AND category = 'primary'",
[new_cat] + all_ids,
)
total_fixed += len(all_ids)
logger.info(
"QC Location: '%s' (%d Eintraege): primary -> %s",
loc_name, len(all_ids), new_cat,
)
if total_fixed > 0:
logger.info(
"QC: %d Karten-Location-Kategorien korrigiert fuer Incident %d",
total_fixed, incident_id,
)
return total_fixed
# ---------------------------------------------------------------------------
# Hauptfunktion
# ---------------------------------------------------------------------------
async def run_post_refresh_qc(db, incident_id: int) -> dict:
"""Fuehrt den kompletten Post-Refresh Quality Check via Haiku durch.
Returns: Dict mit Ergebnissen {facts_removed, locations_fixed}.
"""
try:
# Lage-Titel und Beschreibung laden
cursor = await db.execute(
"SELECT title, description FROM incidents WHERE id = ?",
(incident_id,),
)
row = await cursor.fetchone()
if not row:
return {"facts_removed": 0, "locations_fixed": 0}
incident_title = row["title"] or ""
incident_desc = row["description"] or ""
facts_removed = await check_fact_duplicates(db, incident_id, incident_title)
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 or umlauts_fixed > 0:
await db.commit()
logger.info(
"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,
"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, "umlauts_fixed": 0, "error": str(e)}
# ---------------------------------------------------------------------------
# 3. Umlaut-Normalisierung (deterministisch, Sicherheitsnetz gegen LLM-Drift)
# ---------------------------------------------------------------------------
# Das grosse Mapping wird aus umlaut_dict.json geladen. Das JSON wird einmalig
# aus hunspell-de-de erzeugt (siehe scripts/build_umlaut_dict.py) und enthaelt
# >150.000 deutsche Umlaut-Woerter inklusive Flexionsformen. Mehrdeutigkeiten
# (z. B. "dass"/"daß", "Masse"/"Maße") sind bereits ausgefiltert.
_DICT_PATH = os.path.join(os.path.dirname(__file__), "umlaut_dict.json")
try:
with open(_DICT_PATH, encoding="utf-8") as _dict_file:
_UMLAUT_REPLACEMENTS = json.load(_dict_file)
logger.info("Umlaut-Dict geladen: %d Eintraege aus %s", len(_UMLAUT_REPLACEMENTS), _DICT_PATH)
except FileNotFoundError:
logger.warning("umlaut_dict.json nicht gefunden – Umlaut-Normalisierung laeuft mit leerem Dict")
_UMLAUT_REPLACEMENTS = {}
# _MANUAL_SUPPLEMENT: Lueckenfueller fuer Woerter, die hunspell-de-de nicht abdeckt
# (primaer Komposita und seltene Konjunktiv-Formen). Wird ueber das Korpus-Dict gelegt.
_MANUAL_SUPPLEMENT = {
# Konjunktiv I von "saeen" (selten, aber kommt vor)
"saee": "säe", "saeen": "säen", "gesaet": "gesät",
# Komposita mit Amtstitel, die hunspell als Teile kennt aber nicht kombiniert
"aussenminister": "außenminister", "aussenministerin": "außenministerin",
"aussenministern": "außenministern",
"aussenpolitik": "außenpolitik",
"aussenpolitisch": "außenpolitisch", "aussenpolitische": "außenpolitische",
"aussenpolitischer": "außenpolitischer", "aussenpolitischen": "außenpolitischen",
"vizepraesident": "vizepräsident", "vizepraesidenten": "vizepräsidenten",
"vizepraesidentin": "vizepräsidentin",
"parlamentspraesident": "parlamentspräsident",
"parlamentspraesidenten": "parlamentspräsidenten",
"parlamentspraesidentin": "parlamentspräsidentin",
"generalsekretaer": "generalsekretär", "generalsekretaerin": "generalsekretärin",
"generalsekretaers": "generalsekretärs",
"staatssekretaer": "staatssekretär", "staatssekretaerin": "staatssekretärin",
# Strassen-Komposita
"wasserstrasse": "wasserstraße", "wasserstrassen": "wasserstraßen",
"hauptstrasse": "hauptstraße", "autostrasse": "autostraße",
"bundesstrasse": "bundesstraße", "landstrasse": "landstraße",
# Militaer-Komposita (haeufig in OSINT-Kontext)
"militaerkommando": "militärkommando", "militaerbasis": "militärbasis",
"militaerschlag": "militärschlag", "militaerschlaege": "militärschläge",
# Suedeutsch-Doppel-D-Spezialfall (haendisch korrigierbar)
"suedeutsch": "süddeutsch", "suedeutsche": "süddeutsche",
"suedeutschen": "süddeutschen",
# Fuehrungs- und Oeffnungs-Komposita (hunspell kennt die Stamm-Woerter, nicht die Komposita)
"wiedereroeffnung": "wiedereröffnung", "wiedereroeffnungen": "wiedereröffnungen",
"kriegsfuehrung": "kriegsführung", "kriegsfuehrer": "kriegsführer",
"fuehrungsebene": "führungsebene", "fuehrungsebenen": "führungsebenen",
"fuehrungskraft": "führungskraft", "fuehrungskraefte": "führungskräfte",
"fuehrungsposition": "führungsposition", "fuehrungspositionen": "führungspositionen",
"fuehrungsrolle": "führungsrolle",
"geschaeftsfuehrer": "geschäftsführer", "geschaeftsfuehrung": "geschäftsführung",
"staatsfuehrung": "staatsführung", "parteifuehrung": "parteiführung",
"militaerfuehrung": "militärführung",
}
# Capitalize-Varianten fuer das Supplement (hunspell-Korpus hat sie schon eingebaut)
_MANUAL_SUPPLEMENT_FULL = {}
for _k, _v in _MANUAL_SUPPLEMENT.items():
_MANUAL_SUPPLEMENT_FULL[_k] = _v
if _k[:1].islower():
_MANUAL_SUPPLEMENT_FULL[_k[:1].upper() + _k[1:]] = _v[:1].upper() + _v[1:]
# Supplement ueber das Korpus-Dict legen (Supplement hat Vorrang bei Kollision)
_UMLAUT_REPLACEMENTS = {**_UMLAUT_REPLACEMENTS, **_MANUAL_SUPPLEMENT_FULL}
# Whitelist: Tokens, die trotz Dict-Match NIE ersetzt werden (Eigennamen,
# englische Fremdwoerter, Fachbegriffe). Greift vor dem Dict-Lookup.
_UMLAUT_WHITELIST = frozenset({
# Englische Fremdwoerter
"Boeing", "Business", "Access", "Process", "Message", "Password",
"Miss", "Boss", "Goethe", "Yahoo",
# Eigennamen, die zufaellig "ss" enthalten und nicht umgeschrieben werden sollen
"Israel", "Israels",
})
# Tokenizer: matcht Woerter aus Buchstaben (inkl. deutschen Umlauten).
# Performanter als ein alternierendes Regex ueber 150k Keys — O(1) Dict-Lookup pro Wort.
_WORD_PATTERN = re.compile(r"[A-Za-zÄÖÜäöüß]+")
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 = _WORD_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