fix(bericht): lueckenlose Quellennummern, Medienname statt Feed-Name

Nachtrag zum Quellenverzeichnis, aufgefallen am Export der EU-Lage 53 vom
01.08.2026.

1. Lueckenlose Zaehlung im Bericht. Das Modell nummeriert alle vorgelegten
   Meldungen durch, zitiert aber nur einen Teil. Das Verzeichnis sprang
   dadurch von 20 auf 22, 24, 26, was fachlich stimmt, beim Leser aber wie
   ein Fehler aussieht. Export und Text werden jetzt gemeinsam auf 1 bis n
   umgeschrieben. Die gespeicherten Nummern bleiben unangetastet, damit sie
   ueber Folge-Refreshes stabil bleiben. Verweise, fuer die es keinen
   Verzeichniseintrag gibt, werden dabei entfernt statt verschoben: nach einer
   Umnummerierung wuerden sie sonst auf einen fremden Eintrag zeigen.

2. Der kuratierte Medienname schlaegt den Feed-Namen. Bisher gewann der
   haeufigste gelieferte Name, und das war der Name des RSS-Feeds. Im Bericht
   stand deshalb "Guardian World", "NYT Top Stories", "BBC Europe" und
   "FT Europe". Jetzt: The Guardian, New York Times, BBC, Financial Times.

3. Weiterleitungsportale werden nicht mehr zusammengefasst. Die Gruppierung
   nach Domain warf in der Anthropic-Fassung vier Zeitungen zusammen, weil
   Deutschlandfunk, Kurier, SZ.de und tagesschau.de dort alle als
   news.google.com-Link vorliegen. Der Umweg wird jetzt in der Statistik
   offen ausgewiesen, statt die Medien zu verschmelzen.

4. Ein Quelleneintrag zerfaellt nicht mehr ueber die Seitengrenze. Nummer und
   Name standen am Fuss der einen, die Adresse am Kopf der naechsten Seite.

Gegenprobe an beiden Laeufen des Ceuta-Vergleichs: Lage 53 zeigt 25 Quellen
lueckenlos von 1 bis 25, Lage 52 22 von 1 bis 22, in beiden Faellen loest
jeder Verweis im Text auf und kein Eintrag zerfaellt ueber eine Seite.

Neu sind 11 Pruefungen, insgesamt laufen 129 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-01 21:50:38 +02:00
Ursprung 58aec2edd5
Commit f87d377082
4 geänderte Dateien mit 138 neuen und 10 gelöschten Zeilen

Datei anzeigen

@@ -95,16 +95,75 @@ def _prepare_sources(incident: dict) -> list:
for position, s in enumerate(eintraege, 1): for position, s in enumerate(eintraege, 1):
kopie = dict(s) kopie = dict(s)
kopie["nr"] = _als_nummer(s.get("nr"), position) kopie["nr"] = _als_nummer(s.get("nr"), position)
domain = media_registry.registrable_domain(s.get("url") or "") url = s.get("url") or ""
if domain: domain = media_registry.registrable_domain(url)
# Bei Weiterleitungsportalen sagt die Domain nichts ueber das Medium:
# vier verschiedene Zeitungen liegen dort alle unter news.google.com.
# Der gelieferte Name bleibt deshalb unangetastet.
if domain and not media_registry.ist_aggregator(url):
kopie["name"] = namen.get(domain) or kopie.get("name") or media_registry.name_aus_domain(domain) kopie["name"] = namen.get(domain) or kopie.get("name") or media_registry.name_aus_domain(domain)
kopie["domain"] = domain kopie["domain"] = domain
elif domain:
kopie["domain"] = domain
aufbereitet.append(kopie) aufbereitet.append(kopie)
aufbereitet.sort(key=lambda s: s["nr"] if isinstance(s["nr"], int) else 10**6) aufbereitet.sort(key=lambda s: s["nr"] if isinstance(s["nr"], int) else 10**6)
return aufbereitet return aufbereitet
def _renumber_sources(sources: list) -> tuple[list, dict]:
"""Vergibt fuer die Ausgabe eine lueckenlose Nummerierung ab 1.
Das Modell nummeriert alle vorgelegten Meldungen durch, zitiert aber nur
einen Teil davon. Im Verzeichnis entstehen dadurch Luecken (Lage 53 sprang
von 20 auf 22, 24, 26). Fachlich ist das korrekt, fuer den Leser sieht es
nach einem Fehler aus. Die gespeicherten Nummern bleiben unangetastet,
damit sie ueber Folge-Refreshes stabil bleiben, nur der Bericht zaehlt neu.
Gibt die neu nummerierten Quellen und die Abbildung alt -> neu zurueck.
"""
abbildung: dict[int, int] = {}
neu = []
for position, s in enumerate(sources or [], 1):
kopie = dict(s)
alt = s.get("nr")
if isinstance(alt, int):
abbildung[alt] = position
kopie["nr"] = position
neu.append(kopie)
return neu, abbildung
def _apply_citation_map(text: str, abbildung: dict) -> str:
"""Schreibt [alte Nummer] auf [neue Nummer] um.
Verweise ohne Quelle im Verzeichnis werden entfernt statt umgeschrieben.
Sie waeren nach der Umnummerierung nicht nur unaufloesbar, sondern wuerden
auf einen fremden Eintrag zeigen. Ein stiller Verweis ins Leere ist der
schwerere Fehler als eine fehlende Klammer.
"""
if not text or not abbildung:
return text
verwaist: list[str] = []
def ersetze(m: re.Match) -> str:
roh = m.group(1)
nummer = _als_nummer(roh, -1)
if nummer in abbildung:
return f"[{abbildung[nummer]}]"
verwaist.append(roh)
return ""
ergebnis = re.sub(r"\[(\d{1,5}[a-z]?)\]", ersetze, text)
if verwaist:
logger.warning(
"Bericht: %d Quellenverweise ohne Verzeichniseintrag entfernt (%s)",
len(verwaist), ", ".join(sorted(set(verwaist))[:10]),
)
return ergebnis
def _als_nummer(wert, ersatz: int) -> int: def _als_nummer(wert, ersatz: int) -> int:
"""Quellennummer als ganze Zahl, Buchstaben-Suffixe werden abgeschnitten.""" """Quellennummer als ganze Zahl, Buchstaben-Suffixe werden abgeschnitten."""
if isinstance(wert, int): if isinstance(wert, int):
@@ -140,10 +199,18 @@ def _prepare_source_stats(sources: list, articles: list) -> list:
for s in sources or []: for s in sources or []:
url = s.get("url") or "" url = s.get("url") or ""
domain = media_registry.registrable_domain(url) domain = media_registry.registrable_domain(url)
schluessel = domain or (s.get("name") or "Unbekannt") if domain and media_registry.ist_aggregator(url):
# Weiterleitungsportale sind kein Medium. Zusammenfassen wuerde
# hier vier Zeitungen unter einem Namen verschmelzen, also nach
# Name gruppieren und die Weiterleitung offen ausweisen.
anzeige = f"{s.get('name') or 'Unbekannt'} (Weiterleitung)"
schluessel = f"redirect:{anzeige}"
else:
anzeige = s.get("name") or media_registry.name_aus_domain(domain)
schluessel = domain or anzeige
eintrag = stats_map.setdefault( eintrag = stats_map.setdefault(
schluessel, schluessel,
{"name": s.get("name") or media_registry.name_aus_domain(domain), "count": 0, "langs": set()}, {"name": anzeige, "count": 0, "langs": set()},
) )
eintrag["count"] += 1 eintrag["count"] += 1
aus_url = media_registry.sprache_aus_url(url) aus_url = media_registry.sprache_aus_url(url)
@@ -875,10 +942,10 @@ async def generate_pdf(
# ohne Quellen-Links, Datum/Uhrzeit als eigene Zeile. # ohne Quellen-Links, Datum/Uhrzeit als eigene Zeile.
# - sonst: KI-Executive-Summary (executive_summary_html). # - sonst: KI-Executive-Summary (executive_summary_html).
is_research = incident.get("type") == "research" is_research = incident.get("type") == "research"
all_sources = _prepare_sources(incident) all_sources, citation_map = _renumber_sources(_prepare_sources(incident))
latest_dev = (incident.get("latest_developments") or "").strip() latest_dev = (incident.get("latest_developments") or "").strip()
zusammenfassung_html = executive_summary_html zusammenfassung_html = _apply_citation_map(executive_summary_html, citation_map)
bericht_summary = incident.get("summary", "") bericht_summary = _apply_citation_map(incident.get("summary", ""), citation_map)
zusammenfassung_title = "Zusammenfassung" zusammenfassung_title = "Zusammenfassung"
summary_has_links = True summary_has_links = True
@@ -975,10 +1042,12 @@ async def generate_docx(
# Zusammenfassungs-Quelle bestimmen (analog generate_pdf): # Zusammenfassungs-Quelle bestimmen (analog generate_pdf):
# Research -> Bericht-Extrakt, Live-Monitoring -> "Neueste Entwicklungen", sonst KI. # Research -> Bericht-Extrakt, Live-Monitoring -> "Neueste Entwicklungen", sonst KI.
is_research = incident.get("type") == "research" is_research = incident.get("type") == "research"
all_sources = _prepare_sources(incident) all_sources, citation_map = _renumber_sources(_prepare_sources(incident))
latest_dev = (incident.get("latest_developments") or "").strip() latest_dev = (incident.get("latest_developments") or "").strip()
zusammenfassung_text = executive_summary_text zusammenfassung_text = _apply_citation_map(executive_summary_text, citation_map)
bericht_summary = incident.get("summary") or "Keine Zusammenfassung verfügbar." bericht_summary = _apply_citation_map(
incident.get("summary") or "Keine Zusammenfassung verfügbar.", citation_map
)
zusammenfassung_title = "Zusammenfassung" zusammenfassung_title = "Zusammenfassung"
zusammenfassung_lines: list[str] = [] zusammenfassung_lines: list[str] = []
zusammenfassung_developments: list[tuple[str, str]] = [] zusammenfassung_developments: list[tuple[str, str]] = []

Datei anzeigen

@@ -64,6 +64,9 @@ body { font-family: -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-se
/* Tabellen */ /* Tabellen */
table { width: 100%; border-collapse: collapse; font-size: 9.5pt; margin-bottom: 14px; } table { width: 100%; border-collapse: collapse; font-size: 9.5pt; margin-bottom: 14px; }
.quellen-table { table-layout: fixed; font-size: 8pt; } .quellen-table { table-layout: fixed; font-size: 8pt; }
/* Ein Quelleneintrag darf nicht ueber die Seitengrenze zerfallen: sonst steht
die Nummer am Fuss der einen und die Adresse am Kopf der naechsten Seite. */
tr { page-break-inside: avoid; }
th { background: #0a1832; color: #fff; text-align: left; padding: 6px 10px; font-weight: 600; font-size: 8.5pt; text-transform: uppercase; letter-spacing: 0.5px; } th { background: #0a1832; color: #fff; text-align: left; padding: 6px 10px; font-weight: 600; font-size: 8.5pt; text-transform: uppercase; letter-spacing: 0.5px; }
td { padding: 5px 10px; border-bottom: 1px solid #e0e0e0; } td { padding: 5px 10px; border-bottom: 1px solid #e0e0e0; }
tr:nth-child(even) { background: #f8f9fa; } tr:nth-child(even) { background: #f8f9fa; }

Datei anzeigen

@@ -145,6 +145,13 @@ def namen_je_domain(eintraege: list[tuple[str, str]]) -> dict[str, str]:
ergebnis: dict[str, str] = {} ergebnis: dict[str, str] = {}
for domain, zaehler in kandidaten.items(): for domain, zaehler in kandidaten.items():
# Der kuratierte Name hat Vorrang. Sonst gewinnt der haeufigste
# gelieferte Name, und das ist in der Praxis der Feed-Name: der
# Guardian erschien im Bericht als "Guardian World", die New York
# Times als "NYT Top Stories". Ein Feed ist aber kein Medium.
if domain in _NAMEN_JE_DOMAIN:
ergebnis[domain] = _NAMEN_JE_DOMAIN[domain]
continue
if not zaehler: if not zaehler:
ergebnis[domain] = name_aus_domain(domain) ergebnis[domain] = name_aus_domain(domain)
continue continue

Datei anzeigen

@@ -76,6 +76,20 @@ pruefe("B2 Hostname als Name faellt auf die Tabelle zurueck",
pruefe("B3 unbekannte Domain bekommt einen lesbaren Namen", pruefe("B3 unbekannte Domain bekommt einen lesbaren Namen",
mr.name_aus_domain("beispielzeitung.de") == "Beispielzeitung", mr.name_aus_domain("beispielzeitung.de") == "Beispielzeitung",
mr.name_aus_domain("beispielzeitung.de")) mr.name_aus_domain("beispielzeitung.de"))
# Der Feed-Name darf den Mediennamen nicht verdraengen: im Bericht vom
# 01.08.2026 stand "Guardian World", "NYT Top Stories" und "BBC Europe".
feednamen = mr.namen_je_domain([
("https://www.theguardian.com/world/a", "Guardian World"),
("https://www.theguardian.com/world/b", "Guardian World"),
("https://www.nytimes.com/2026/08/01/x.html", "NYT Top Stories"),
("https://www.bbc.co.uk/news/y", "BBC Europe"),
])
pruefe("B4 Medienname schlaegt Feed-Name (Guardian)",
feednamen.get("theguardian.com") == "The Guardian", feednamen.get("theguardian.com"))
pruefe("B5 Medienname schlaegt Feed-Name (NYT)",
feednamen.get("nytimes.com") == "New York Times", feednamen.get("nytimes.com"))
pruefe("B6 Medienname schlaegt Feed-Name (BBC)",
feednamen.get("bbc.co.uk") == "BBC", feednamen.get("bbc.co.uk"))
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -114,6 +128,26 @@ pruefe("C4 Verzeichnis fuehrt den Verlag unter einem Namen",
namen_liste == {"The Guardian"}, namen_liste) namen_liste == {"The Guardian"}, namen_liste)
# ---------------------------------------------------------------------------
# C2. Lueckenlose Nummerierung fuer die Ausgabe
# ---------------------------------------------------------------------------
print("\nC2. Umnummerierung im Bericht")
neu, abbildung = rg._renumber_sources(sources)
pruefe("C5 Ausgabe zaehlt lueckenlos ab 1",
[s["nr"] for s in neu] == list(range(1, len(sources) + 1)), [s["nr"] for s in neu])
pruefe("C6 Abbildung fuehrt die hoechste alte Nummer auf die letzte Zeile",
abbildung.get(28) == len(sources), abbildung.get(28))
text_alt = "Erstens [1], zweitens [22] und drittens [28]."
text_neu = rg._apply_citation_map(text_alt, abbildung)
pruefe("C7 Verweise im Text folgen der neuen Zaehlung",
text_neu == f"Erstens [1], zweitens [{abbildung[22]}] und drittens [{abbildung[28]}].", text_neu)
verwaist = rg._apply_citation_map("Beleg [21] existiert nicht, [1] schon.", abbildung)
pruefe("C8 Verweis ohne Verzeichniseintrag wird entfernt, nicht verschoben",
"[21]" not in verwaist and "[1]" in verwaist and "[2]" not in verwaist, verwaist)
pruefe("C9 leere Abbildung laesst den Text unveraendert",
rg._apply_citation_map(text_alt, {}) == text_alt)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# D. Statistik und Verzeichnis widersprechen sich nicht mehr # D. Statistik und Verzeichnis widersprechen sich nicht mehr
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -139,6 +173,21 @@ pruefe("D2 zwei France24-Belege ergeben eine Zeile",
elpais = [s for s in stats if "Pa" in s["name"] or "País" in s["name"]] elpais = [s for s in stats if "Pa" in s["name"] or "País" in s["name"]]
pruefe("D3 englische Ausgabe wird nicht als DE gefuehrt", pruefe("D3 englische Ausgabe wird nicht als DE gefuehrt",
elpais and elpais[0]["languages"] == "EN", elpais) elpais and elpais[0]["languages"] == "EN", elpais)
# Weiterleitungen: vier Zeitungen liegen alle unter news.google.com. Sie
# duerfen nicht zu einer Zeile verschmelzen und nicht den Namen tauschen.
redirects = rg._prepare_sources({"sources_json": json.dumps([
{"nr": 1, "name": "SZ.de", "url": "https://news.google.com/rss/articles/AAA?oc=5"},
{"nr": 2, "name": "Kurier", "url": "https://news.google.com/rss/articles/BBB?oc=5"},
{"nr": 3, "name": "Deutschlandfunk", "url": "https://news.google.com/rss/articles/CCC?oc=5"},
], ensure_ascii=False)})
pruefe("D6 Weiterleitungen behalten ihren eigenen Namen",
[s["name"] for s in redirects] == ["SZ.de", "Kurier", "Deutschlandfunk"],
[s["name"] for s in redirects])
redirect_stats = rg._prepare_source_stats(redirects, [])
pruefe("D7 drei Zeitungen bleiben drei Zeilen", len(redirect_stats) == 3, redirect_stats)
pruefe("D8 Weiterleitung wird ausgewiesen",
all("(Weiterleitung)" in s["name"] for s in redirect_stats), redirect_stats)
notiz = rg._source_stats_note(zitiert, artikel) notiz = rg._source_stats_note(zitiert, artikel)
pruefe("D4 Hinweis nennt zitierte Belege und ausgewertete Meldungen", pruefe("D4 Hinweis nennt zitierte Belege und ausgewertete Meldungen",
"4 im Lagebild zitierten Belege" in notiz and "5 Meldungen" in notiz, notiz) "4 im Lagebild zitierten Belege" in notiz and "5 Meldungen" in notiz, notiz)