diff --git a/CLAUDE.md b/CLAUDE.md index 39c97e7..2897198 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -165,6 +165,7 @@ tests/: test_qc_und_runden.py: "Absicherung der Duplikatpruefung und Abbruch der Suchrunden bei erreichter Treffergrenze" test_mehrfachantwort.py: "Antworten, in denen sich das Modell selbst korrigiert und mehrere Fassungen liefert, die ausfuehrlichste muss gewinnen" test_faktencheck_belegtiefe.py: "Belegzaehlung nach Medienhaus, Nachbedingung fuer bestaetigte Fakten, Sperre gegen das Hochstufen von developing, getrennte Bezeichnungen fuer Widerspruch und Widerlegung" + test_rss_treffer.py: "Keyword-Matching der RSS-Auswertung: Mehrwort-Begriffe treffen wortweise, Auftrag verlangt Einzelbegriffe und Eigennamen" test_bedrock_wiederholung.py: "Wiederholung des EU-Wegs bei Kapazitaets- und Kontingentfehlern, Staffelung, Zeit- und Wartebudget, Sichtbarkeit im Refresh-Protokoll" test_quellenausgabe.py: "Ausgabetreue des Quellenverzeichnisses: Nummern aus dem Lagebild statt Zeilennummern, keine Kappung, ein Verlag ein Name, Statistik deckungsgleich mit dem Verzeichnis" ``` diff --git a/src/agents/factchecker.py b/src/agents/factchecker.py index e848cd2..4f9deaa 100644 --- a/src/agents/factchecker.py +++ b/src/agents/factchecker.py @@ -434,9 +434,14 @@ def zaehle_medienhaeuser(urls: list[str]) -> int: Sechs France24-Treffer, davon vier Videofassungen derselben Meldung, sind ein Haus und kein sechsfacher Beleg. Gezaehlt wird deshalb nach - registrierbarer Domain. Weiterleitungsportale bekommen keine gemeinsame - Identitaet, weil hinter news.google.com beliebige Zeitungen stecken - koennen: dort zaehlt jede Adresse einzeln. + registrierbarer Domain. + + Weiterleitungen ueber Sammelportale zaehlen zusammen als EIN Beleg. Sie + verbergen ihr Zielmedium, also laesst sich nicht behaupten, dass dahinter + verschiedene Redaktionen stehen. Vier news.google.com-Adressen als vier + unabhaengige Quellen zu zaehlen, hat im Vergleich vom 02.08.2026 den + Anthropic-Weg rechnerisch bessergestellt: dort standen 10 Belege fuer + tatsaechlich 7 Haeuser, davon vier Weiterleitungen. """ from services import media_registry @@ -448,7 +453,7 @@ def zaehle_medienhaeuser(urls: list[str]) -> int: domain = media_registry.registrable_domain(sauber) if not domain: continue - haeuser.add(sauber.lower() if media_registry.ist_aggregator(sauber) else domain) + haeuser.add("weiterleitung" if media_registry.ist_aggregator(sauber) else domain) return len(haeuser) diff --git a/src/agents/researcher.py b/src/agents/researcher.py index ecea3e7..4d943a7 100644 --- a/src/agents/researcher.py +++ b/src/agents/researcher.py @@ -413,6 +413,15 @@ KEYWORDS-REGELN: - Wenn die Lage rein deutsch oder englisch ist und keine fremdsprachigen Feeds gewählt werden, reichen "de" und/oder "en". - Nur inhaltlich relevante Begriffe (Personen, Orte, Themen, Organisationen) +- EINZELWÖRTER, keine Phrasen. "ceuta" und "migranten" statt "migrationskrise spanien". + Die Begriffe werden gegen Schlagzeilen geprüft, und dort steht selten die exakte + Wortfolge einer Phrase. Nur wo ein Begriff untrennbar ist (Eigenname wie + "nord stream"), darf er aus zwei Wörtern bestehen. +- Die HARTEN Eigennamen der Lage MÜSSEN dabei sein: Schauplatz (Ort, Region), beteiligte + Länder, handelnde Personen, betroffene Organisationen. Eine Lage über Ceuta ohne das + Wort "ceuta" ist unbrauchbar. Diese Namen zuerst nennen, danach die Sachbegriffe. +- KEINE abstrakten Politikbegriffe als Suchwort ("aufnahmeverfahren", "grenzschutz europa"). + Sie stehen fast nie in einer Schlagzeile und ziehen dafür themenfremde Behördenfeeds an. - KEINE Jahreszahlen (2024, 2025, 2026 etc.) - KEINE Monatsnamen (Januar, Februar, März etc.) - KEINE generischen Wörter (aktuell, news, update etc.) diff --git a/src/feeds/rss_parser.py b/src/feeds/rss_parser.py index 1af2d52..ecd191d 100644 --- a/src/feeds/rss_parser.py +++ b/src/feeds/rss_parser.py @@ -18,6 +18,30 @@ from agents.researcher import keywords_for_language, flatten_keywords logger = logging.getLogger("osint.rss") +def wort_trifft(wort: str, text: str) -> bool: + """Prueft ein Suchwort gegen den Text, Mehrwort-Begriffe wortweise. + + Die Keyword-Erzeugung liefert je nach Lagentitel Einzelbegriffe ("ceuta") + oder Phrasen ("migrationskrise spanien"). Ein reiner Teilstring-Vergleich + laesst Phrasen praktisch immer durchfallen: Die Schlagzeile "Migrationskrise + in Spanien" enthaelt die Folge "migrationskrise spanien" nicht, wegen des + Wortes dazwischen. Am 01.08.2026 kostete das den Unterschied zwischen 79 + und 17 RSS-Treffern fuer dieselbe Lage. + + Deshalb: Eine Phrase trifft, wenn ALLE ihre Bestandteile im Text vorkommen, + unabhaengig von Reihenfolge und Abstand. Die Genauigkeit bleibt damit + erhalten, nur die starre Reihenfolge faellt weg. + """ + if not wort: + return False + if wort in text: + return True + teile = [t for t in wort.split() if t] + if len(teile) < 2: + return False + return all(t in text for t in teile) + + def _is_specific_word(w: str) -> bool: """Spezifisches Keyword = 1-Treffer reicht für Match. @@ -209,12 +233,14 @@ class RSSParser: # CJK/Arabisch/Hebräisch/Kyrillisch ≥3 Zeichen) im Text reicht 1 Treffer. # Damit matched z.B. "自衛隊" (3 Kanji) wie "buckelwal" (9 Zeichen). # - Sonst: alte Heuristik (mindestens halb der Wörter, max. 2). - specific_in_text = any(w in text for w in search_words if _is_specific_word(w)) + specific_in_text = any( + wort_trifft(w, text) for w in search_words if _is_specific_word(w) + ) if specific_in_text: min_matches = 1 else: min_matches = min(2, max(1, (len(search_words) + 1) // 2)) - match_count = sum(1 for word in search_words if word in text) + match_count = sum(1 for word in search_words if wort_trifft(word, text)) if match_count >= min_matches: published = None diff --git a/src/report_generator.py b/src/report_generator.py index d604ed6..0da3872 100644 --- a/src/report_generator.py +++ b/src/report_generator.py @@ -112,7 +112,49 @@ def _prepare_sources(incident: dict) -> list: aufbereitet.append(kopie) aufbereitet.sort(key=lambda s: s["nr"] if isinstance(s["nr"], int) else 10**6) - return aufbereitet + return _dubletten_zusammenfuehren(aufbereitet) + + +def _url_schluessel(url: str) -> str: + """Vergleichsform einer Adresse: ohne Schema, ohne www, ohne Endschraegstrich.""" + text = (url or "").strip().lower() + for praefix in ("https://", "http://"): + if text.startswith(praefix): + text = text[len(praefix):] + if text.startswith("www."): + text = text[4:] + return text.rstrip("/") + + +def _dubletten_zusammenfuehren(sources: list) -> list: + """Fuehrt Eintraege mit identischer Adresse auf eine Nummer zusammen. + + Im Bericht vom 02.08.2026 standen 30 Eintraege fuer 28 verschiedene + Adressen: zweimal dieselbe tagesschau-Meldung unter je zwei Nummern. Eine + Behauptung wirkte dadurch mit "3 Quellen" belegt, obwohl zwei davon + derselbe Artikel waren. Die kleinere Nummer gewinnt, die groessere wird + unter "alias_nrs" vermerkt, damit die Verweise im Text auf sie umgebogen + werden koennen. + """ + behalten: dict[str, dict] = {} + reihenfolge: list[str] = [] + for s in sources: + schluessel = _url_schluessel(s.get("url") or "") + if not schluessel: + reihenfolge.append(id(s)) + behalten[id(s)] = s + continue + if schluessel in behalten: + behalten[schluessel].setdefault("alias_nrs", []).append(s.get("nr")) + continue + behalten[schluessel] = s + reihenfolge.append(schluessel) + + ergebnis = [behalten[k] for k in reihenfolge] + entfernt = len(sources) - len(ergebnis) + if entfernt: + logger.info("Quellenverzeichnis: %d doppelte Adresse(n) zusammengefuehrt", entfernt) + return ergebnis def _renumber_sources(sources: list) -> tuple[list, dict]: @@ -133,6 +175,11 @@ def _renumber_sources(sources: list) -> tuple[list, dict]: alt = s.get("nr") if isinstance(alt, int): abbildung[alt] = position + # Zusammengefuehrte Dubletten zeigen auf denselben Eintrag, damit ein + # Verweis auf die entfernte Nummer nicht ins Leere laeuft. + for alias in kopie.pop("alias_nrs", []) or []: + if isinstance(alias, int): + abbildung[alias] = position kopie["nr"] = position neu.append(kopie) return neu, abbildung @@ -352,8 +399,16 @@ def _parse_developments_for_export(text: str) -> list[tuple[str, str]]: continue yy = year[-2:] if year else year2 label = f"{int(day):02d}.{int(month):02d}.{yy}, {time} Uhr" - result.append((label, body)) - return result + stunde, minute = (time.split(":") + ["0"])[:2] + sortierwert = (int(yy), int(month), int(day), int(stunde), int(minute)) + result.append((label, body, sortierwert)) + + # Neueste zuerst. Der Auftrag verlangt diese Reihenfolge zwar schon vom + # Modell, im Bericht vom 02.08.2026 sprang der letzte Eintrag aber von + # 12:44 zurueck auf 17:47. Im prominentesten Kapitel darf das nicht von + # der Sorgfalt des Modells abhaengen, deshalb hier deterministisch. + result.sort(key=lambda e: e[2], reverse=True) + return [(label, body) for label, body, _ in result] def _format_latest_developments_html(text: str) -> str: diff --git a/tests/test_faktencheck_belegtiefe.py b/tests/test_faktencheck_belegtiefe.py index 3c7e6b4..bfdf7a9 100644 --- a/tests/test_faktencheck_belegtiefe.py +++ b/tests/test_faktencheck_belegtiefe.py @@ -66,8 +66,21 @@ redirects = [ "https://news.google.com/rss/articles/AAA?oc=5", "https://news.google.com/rss/articles/BBB?oc=5", ] -pruefe("A3 zwei Weiterleitungen bleiben zwei, sie koennen zwei Zeitungen sein", - fc.zaehle_medienhaeuser(redirects) == 2, fc.zaehle_medienhaeuser(redirects)) +pruefe("A3 Weiterleitungen zaehlen zusammen als ein Beleg", + fc.zaehle_medienhaeuser(redirects) == 1, fc.zaehle_medienhaeuser(redirects)) +# Der gemeldete Fall aus Lage 57: 11 Adressen, davon vier Weiterleitungen, +# ergaben eine ausgewiesene Belegzahl von 10 bei tatsaechlich 7 Haeusern. +lage57 = [ + "https://news.google.com/rss/articles/A?oc=5", + "https://news.google.com/rss/articles/B?oc=5", + "https://news.google.com/rss/articles/C?oc=5", + "https://news.google.com/rss/articles/D?oc=5", + "https://www.tagesschau.de/a.html", "https://www.tagesschau.de/b.html", + "https://www.france24.com/x", "https://www.theguardian.com/y", + "https://www.ft.com/z", "https://www.aljazeera.com/q", "https://www.bbc.co.uk/r", +] +pruefe("A3b elf Adressen mit vier Weiterleitungen ergeben sieben Belege", + fc.zaehle_medienhaeuser(lage57) == 7, fc.zaehle_medienhaeuser(lage57)) pruefe("A4 Satzzeichen am Adressende stoeren nicht", fc.zaehle_medienhaeuser(["https://www.zeit.de/a.html)."]) == 1) pruefe("A5 leere Liste ergibt null", fc.zaehle_medienhaeuser([]) == 0) diff --git a/tests/test_quellenausgabe.py b/tests/test_quellenausgabe.py index 9e436df..8fddef3 100644 --- a/tests/test_quellenausgabe.py +++ b/tests/test_quellenausgabe.py @@ -148,6 +148,53 @@ pruefe("C9 leere Abbildung laesst den Text unveraendert", rg._apply_citation_map(text_alt, {}) == text_alt) +# --------------------------------------------------------------------------- +# C3. Doppelte Adressen +# --------------------------------------------------------------------------- +print("\nC3. Doppelte Adressen im Verzeichnis") +# Nachgebaut nach Lage 56: 30 Eintraege, aber nur 28 verschiedene Adressen. +# Zweimal dieselbe tagesschau-Meldung stuetzte dort die strittigste Behauptung. +doppelt = rg._prepare_sources({"sources_json": json.dumps([ + {"nr": 22, "name": "tagesschau", "url": "https://www.tagesschau.de/ausland/a-100.html"}, + {"nr": 23, "name": "tagesschau", "url": "https://www.tagesschau.de/ausland/a-100.html"}, + {"nr": 24, "name": "tagesschau", "url": "https://www.tagesschau.de/ausland/b-104.html"}, + {"nr": 25, "name": "tagesschau", "url": "http://tagesschau.de/ausland/b-104.html/"}, + {"nr": 26, "name": "ZDF heute", "url": "https://www.zdfheute.de/c.html"}, +], ensure_ascii=False)}) +pruefe("C10 gleiche Adresse ergibt einen Eintrag", len(doppelt) == 3, len(doppelt)) +pruefe("C11 die kleinere Nummer bleibt bestehen", + [s["nr"] for s in doppelt] == [22, 24, 26], [s["nr"] for s in doppelt]) +pruefe("C12 Schema, www und Endschraegstrich zaehlen nicht als Unterschied", + rg._url_schluessel("https://www.x.de/a/") == rg._url_schluessel("http://x.de/a")) + +neu_d, abb_d = rg._renumber_sources(doppelt) +pruefe("C13 Ausgabe zaehlt wieder lueckenlos", [s["nr"] for s in neu_d] == [1, 2, 3], + [s["nr"] for s in neu_d]) +pruefe("C14 Verweis auf die entfernte Nummer zeigt auf den behaltenen Eintrag", + abb_d.get(23) == abb_d.get(22) == 1 and abb_d.get(25) == abb_d.get(24) == 2, abb_d) +pruefe("C15 Text mit beiden Nummern bleibt aufloesbar", + rg._apply_citation_map("Beleg [22] und [23].", abb_d) == "Beleg [1] und [1].", + rg._apply_citation_map("Beleg [22] und [23].", abb_d)) + + +# --------------------------------------------------------------------------- +# C4. Zeitliche Sortierung der Neuesten Entwicklungen +# --------------------------------------------------------------------------- +print("\nC4. Neueste Entwicklungen") +# Nachgebaut nach Lage 56: der letzte Eintrag sprang von 12:44 zurueck auf 17:47. +roh = "\n".join([ + "- [01.08. 23:23] Barriere installiert. {A|https://a.de/1}", + "- [01.08. 12:44] Italien setzt Schengen aus. {B|https://b.de/1}", + "- [01.08. 17:47] Todeszahl steigt. {C|https://c.de/1}", + "- [02.08. 00:05] Neuer Tag. {D|https://d.de/1}", +]) +paare = rg._parse_developments_for_export(roh) +pruefe("C16 Eintraege stehen absteigend nach Zeit", + [p[0][:14] for p in paare] == ["02.08.26, 00:0", "01.08.26, 23:2", "01.08.26, 17:4", "01.08.26, 12:4"], + [p[0] for p in paare]) +pruefe("C17 kein Eintrag geht verloren", len(paare) == 4, len(paare)) + + # --------------------------------------------------------------------------- # D. Statistik und Verzeichnis widersprechen sich nicht mehr # --------------------------------------------------------------------------- diff --git a/tests/test_rss_treffer.py b/tests/test_rss_treffer.py new file mode 100644 index 0000000..e44cd42 --- /dev/null +++ b/tests/test_rss_treffer.py @@ -0,0 +1,105 @@ +"""Testet das Keyword-Matching der RSS-Auswertung. + +Hintergrund: Dieselbe Lage lieferte am 01.08.2026 je nach Titel 79 oder 17 +RSS-Treffer. Ursache war die Kombination aus zwei Dingen. Die +Keyword-Erzeugung lieferte fuer den politisch formulierten Titel Phrasen +("migrationskrise spanien", "aufnahmeverfahren eu") statt Einzelbegriffe, und +das Matching verglich diese Phrasen als starre Zeichenfolge. Die Schlagzeile +"Migrationskrise in Spanien" enthaelt "migrationskrise spanien" nicht, wegen +des Wortes dazwischen. Aus 53 Artikeln wurden so 16. + +Laeuft ohne Netzzugriff und ohne Kosten. + +Aufruf aus dem Projektstamm: + venv/bin/python tests/test_rss_treffer.py +""" +import os +import sys + +sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "src")) + +from feeds.rss_parser import wort_trifft, _is_specific_word # noqa: E402 +from agents.researcher import FEED_SELECTION_PROMPT_TEMPLATE # noqa: E402 + +ok = 0 +fail = 0 + + +def pruefe(name, bedingung, extra=""): + global ok, fail + if bedingung: + ok += 1 + print(" OK " + name) + else: + fail += 1 + print(" FEHL " + name + " " + str(extra)) + + +SCHLAGZEILE = ( + "migrationskrise in spanien: eu-innenminister beraten am dienstag über ceuta" +).lower() + + +# --------------------------------------------------------------------------- +# A. Einzelbegriffe verhalten sich wie bisher +# --------------------------------------------------------------------------- +print("\nA. Einzelbegriffe") +pruefe("A1 enthaltenes Wort trifft", wort_trifft("ceuta", SCHLAGZEILE)) +pruefe("A2 nicht enthaltenes Wort trifft nicht", not wort_trifft("marokko", SCHLAGZEILE)) +pruefe("A3 Wortteil trifft weiterhin (Komposita)", wort_trifft("migration", SCHLAGZEILE)) +pruefe("A4 leeres Wort trifft nie", not wort_trifft("", SCHLAGZEILE)) + + +# --------------------------------------------------------------------------- +# B. Phrasen treffen wortweise +# --------------------------------------------------------------------------- +print("\nB. Mehrwort-Begriffe") +pruefe("B1 Phrase mit Wort dazwischen trifft jetzt", + wort_trifft("migrationskrise spanien", SCHLAGZEILE)) +pruefe("B2 Phrase in umgekehrter Reihenfolge trifft", + wort_trifft("spanien migrationskrise", SCHLAGZEILE)) +pruefe("B3 zusammenhaengende Phrase trifft weiterhin", + wort_trifft("eu-innenminister beraten", SCHLAGZEILE)) +pruefe("B4 fehlt ein Bestandteil, trifft die Phrase nicht", + not wort_trifft("migrationskrise portugal", SCHLAGZEILE)) +pruefe("B5 abstrakte Phrase ohne Bezug trifft nicht", + not wort_trifft("aufnahmeverfahren eu", SCHLAGZEILE)) + +# Der gemeldete Fall: zehn Begriffe, davon neun Phrasen. +KEYWORDS_LAGE_55 = [ + "migrationskrise spanien", "eu-innenminister", "südgrenze spanien", + "mittelmeer migration", "grenzschutz europa", "flüchtlinge mittelmeer", + "überfahrten mittelmeer", "schengen-außengrenzen", "europol migration", + "aufnahmeverfahren eu", +] +treffer = sum(1 for w in KEYWORDS_LAGE_55 if wort_trifft(w, SCHLAGZEILE)) +alt_treffer = sum(1 for w in KEYWORDS_LAGE_55 if w in SCHLAGZEILE) +pruefe("B6 der gemeldete Keyword-Satz trifft die Schlagzeile jetzt", + treffer >= 2, (treffer, alt_treffer)) +pruefe("B7 vorher traf nur der eine Einzelbegriff", alt_treffer == 1, alt_treffer) + +# Die Schwelle bleibt unveraendert streng: ohne spezifisches Wort im Text +# braucht es mehrere Treffer. +FREMDE = "fussball bundesliga spieltag zusammenfassung".lower() +pruefe("B8 fremde Schlagzeile bleibt ohne Treffer", + sum(1 for w in KEYWORDS_LAGE_55 if wort_trifft(w, FREMDE)) == 0) +pruefe("B9 Spezifik-Erkennung unveraendert", + _is_specific_word("migrationskrise spanien") and not _is_specific_word("eu")) + + +# --------------------------------------------------------------------------- +# C. Der Auftrag verlangt Einzelbegriffe und Eigennamen +# --------------------------------------------------------------------------- +print("\nC. Auftrag an die Keyword-Erzeugung") +for stichwort, name in ( + ("EINZELWÖRTER", "C1 Einzelwoerter statt Phrasen gefordert"), + ("Eigennamen der Lage MÜSSEN", "C2 Eigennamen sind Pflicht"), + ("Schauplatz", "C3 Schauplatz wird ausdruecklich genannt"), + ("KEINE abstrakten Politikbegriffe", "C4 abstrakte Begriffe ausgeschlossen"), +): + pruefe(name, stichwort in FEED_SELECTION_PROMPT_TEMPLATE) +pruefe("C5 Beispiel nennt den konkreten Fehlerfall", + "ceuta" in FEED_SELECTION_PROMPT_TEMPLATE.lower()) + +print("\nErgebnis: " + str(ok) + " bestanden, " + str(fail) + " fehlgeschlagen") +sys.exit(1 if fail else 0)