fix(recherche): Suchbegriffe treffen wieder, Verzeichnis ohne Dubletten

Vier Befunde aus dem Vergleich vom 02.08.2026, sortiert nach Wirkung.

1. Mehrwort-Suchbegriffe fielen im RSS-Abgleich durch. Dieselbe Lage lieferte
   je nach Titel 79 oder 17 Treffer. Die Keyword-Erzeugung liefert bei
   politisch formulierten Titeln Phrasen ("migrationskrise spanien",
   "aufnahmeverfahren eu"), das Matching verglich sie als starre Zeichenfolge.
   Die Schlagzeile "Migrationskrise in Spanien" enthaelt "migrationskrise
   spanien" nicht, wegen des Wortes dazwischen. Aus 53 Artikeln wurden so 16.
   Eine Phrase trifft jetzt, wenn alle ihre Bestandteile im Text vorkommen,
   unabhaengig von Reihenfolge und Abstand. Die Genauigkeit bleibt erhalten,
   nur die starre Reihenfolge faellt weg.

   Dazu der Auftrag an die Keyword-Erzeugung: Einzelwoerter statt Phrasen, und
   die harten Eigennamen der Lage sind Pflicht. Bei den betroffenen Laeufen
   fehlte "ceuta" komplett, dafuer stand "europol migration" in der Liste, was
   den Europol-Feed anzog und sieben themenfremde Pressemitteilungen
   einbrachte, die der Topic-Filter danach wieder aussortieren musste.

2. Dubletten im Quellenverzeichnis. Lage 56 fuehrte 30 Eintraege fuer 28
   Adressen, zweimal dieselbe tagesschau-Meldung unter je zwei Nummern.
   Ausgerechnet die strittigste Behauptung, der offene Brief der 22 Staaten,
   stuetzte sich damit auf "3 Quellen", von denen zwei derselbe Artikel waren.
   Gleiche Adresse heisst jetzt ein Eintrag, Verweise auf die entfernte Nummer
   werden auf den behaltenen umgebogen. Verglichen wird ohne Schema, www und
   Endschraegstrich.

3. Die Zeitleiste wird deterministisch sortiert. Im selben Bericht sprang der
   letzte Eintrag von 12:44 zurueck auf 17:47. Der Auftrag verlangt die
   Sortierung zwar vom Modell, im prominentesten Kapitel darf sie aber nicht
   von dessen Sorgfalt abhaengen. Gegenprobe an Lage 56: 23:23, 22:09, 17:47,
   16:58 statt des Sprungs ans Ende.

4. Weiterleitungen zaehlen als ein Beleg. Sie verbergen ihr Zielmedium, also
   laesst sich nicht behaupten, dass dahinter verschiedene Redaktionen stehen.
   Die bisherige Einzelzaehlung stellte den Anthropic-Weg rechnerisch besser:
   in Lage 57 standen 10 ausgewiesene Belege fuer 7 Haeuser, weil vier der elf
   Adressen news.google.com-Weiterleitungen waren.

Gegenprobe an beiden gespeicherten Laeufen: Lage 56 zeigt 28 statt 30
Verzeichniseintraege, beide Berichte zaehlen lueckenlos, die Zeitleiste ist
sortiert.

Neu sind 26 Pruefungen, insgesamt laufen 206 ohne Netzzugriff und ohne Kosten.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Dieser Commit ist enthalten in:
claude-dev
2026-08-02 00:43:28 +02:00
Ursprung de3cb7223f
Commit 9868a9748a
8 geänderte Dateien mit 272 neuen und 11 gelöschten Zeilen

Datei anzeigen

@@ -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)

Datei anzeigen

@@ -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.)

Datei anzeigen

@@ -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

Datei anzeigen

@@ -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: