diff --git a/src/report_generator.py b/src/report_generator.py index 55cf49f..7ea86a5 100644 --- a/src/report_generator.py +++ b/src/report_generator.py @@ -95,16 +95,75 @@ def _prepare_sources(incident: dict) -> list: for position, s in enumerate(eintraege, 1): kopie = dict(s) kopie["nr"] = _als_nummer(s.get("nr"), position) - domain = media_registry.registrable_domain(s.get("url") or "") - if domain: + url = s.get("url") or "" + 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["domain"] = domain + elif domain: + kopie["domain"] = domain aufbereitet.append(kopie) aufbereitet.sort(key=lambda s: s["nr"] if isinstance(s["nr"], int) else 10**6) 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: """Quellennummer als ganze Zahl, Buchstaben-Suffixe werden abgeschnitten.""" if isinstance(wert, int): @@ -140,10 +199,18 @@ def _prepare_source_stats(sources: list, articles: list) -> list: for s in sources or []: url = s.get("url") or "" 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( 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 aus_url = media_registry.sprache_aus_url(url) @@ -875,10 +942,10 @@ async def generate_pdf( # ohne Quellen-Links, Datum/Uhrzeit als eigene Zeile. # - sonst: KI-Executive-Summary (executive_summary_html). 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() - zusammenfassung_html = executive_summary_html - bericht_summary = incident.get("summary", "") + zusammenfassung_html = _apply_citation_map(executive_summary_html, citation_map) + bericht_summary = _apply_citation_map(incident.get("summary", ""), citation_map) zusammenfassung_title = "Zusammenfassung" summary_has_links = True @@ -975,10 +1042,12 @@ async def generate_docx( # Zusammenfassungs-Quelle bestimmen (analog generate_pdf): # Research -> Bericht-Extrakt, Live-Monitoring -> "Neueste Entwicklungen", sonst KI. 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() - zusammenfassung_text = executive_summary_text - bericht_summary = incident.get("summary") or "Keine Zusammenfassung verfügbar." + zusammenfassung_text = _apply_citation_map(executive_summary_text, citation_map) + bericht_summary = _apply_citation_map( + incident.get("summary") or "Keine Zusammenfassung verfügbar.", citation_map + ) zusammenfassung_title = "Zusammenfassung" zusammenfassung_lines: list[str] = [] zusammenfassung_developments: list[tuple[str, str]] = [] diff --git a/src/report_templates/report.html b/src/report_templates/report.html index 6a1aad5..7c5de90 100644 --- a/src/report_templates/report.html +++ b/src/report_templates/report.html @@ -64,6 +64,9 @@ body { font-family: -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-se /* Tabellen */ table { width: 100%; border-collapse: collapse; font-size: 9.5pt; margin-bottom: 14px; } .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; } td { padding: 5px 10px; border-bottom: 1px solid #e0e0e0; } tr:nth-child(even) { background: #f8f9fa; } diff --git a/src/services/media_registry.py b/src/services/media_registry.py index a56bee0..401125a 100644 --- a/src/services/media_registry.py +++ b/src/services/media_registry.py @@ -145,6 +145,13 @@ def namen_je_domain(eintraege: list[tuple[str, str]]) -> dict[str, str]: ergebnis: dict[str, str] = {} 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: ergebnis[domain] = name_aus_domain(domain) continue diff --git a/tests/test_quellenausgabe.py b/tests/test_quellenausgabe.py index 3a474b5..9e436df 100644 --- a/tests/test_quellenausgabe.py +++ b/tests/test_quellenausgabe.py @@ -76,6 +76,20 @@ pruefe("B2 Hostname als Name faellt auf die Tabelle zurueck", pruefe("B3 unbekannte Domain bekommt einen lesbaren Namen", mr.name_aus_domain("beispielzeitung.de") == "Beispielzeitung", 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) +# --------------------------------------------------------------------------- +# 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 # --------------------------------------------------------------------------- @@ -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"]] pruefe("D3 englische Ausgabe wird nicht als DE gefuehrt", 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) pruefe("D4 Hinweis nennt zitierte Belege und ausgewertete Meldungen", "4 im Lagebild zitierten Belege" in notiz and "5 Meldungen" in notiz, notiz)