Dateien
AegisSight-Monitor/tests/test_quellenausgabe.py
claude-dev f87d377082 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>
2026-08-01 21:50:38 +02:00

234 Zeilen
12 KiB
Python

"""Testet die Ausgabetreue des Quellenverzeichnisses.
Hintergrund ist der Vergleich zweier Berichte zur selben Lage am 01.08.2026
(Ceuta). Beide Fassungen verwiesen im Text auf Quellennummern, die im
gedruckten Verzeichnis nicht auftauchten. Ursache war nicht die Analyse, in der
Datenbank passten Text und Liste zusammen, sondern die Ausgabe: Das Template
druckte die laufende Zeilennummer statt der Quellennummer aus dem Lagebild.
Sobald die Nummern Luecken haben, verschiebt sich damit jeder Beleg.
Laeuft ohne Netzzugriff, ohne Kosten und ohne Datenbank.
Aufruf aus dem Projektstamm:
venv/bin/python tests/test_quellenausgabe.py
"""
import json
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "src"))
from jinja2 import Environment, FileSystemLoader # noqa: E402
import report_generator as rg # noqa: E402
from services import media_registry as mr # 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))
# ---------------------------------------------------------------------------
# A. Domain-Erkennung
# ---------------------------------------------------------------------------
print("\nA. Registrierbare Domain")
pruefe("A1 www wird abgeschnitten",
mr.registrable_domain("https://www.theguardian.com/world/x") == "theguardian.com",
mr.registrable_domain("https://www.theguardian.com/world/x"))
pruefe("A2 mehrteilige Endung bleibt erhalten",
mr.registrable_domain("https://www.bbc.co.uk/news/articles/y") == "bbc.co.uk",
mr.registrable_domain("https://www.bbc.co.uk/news/articles/y"))
pruefe("A3 Sprachsubdomain zaehlt zur selben Domain",
mr.registrable_domain("https://english.elpais.com/spain/a.html") == "elpais.com",
mr.registrable_domain("https://english.elpais.com/spain/a.html"))
pruefe("A4 leere Eingabe bleibt leer", mr.registrable_domain("") == "")
pruefe("A5 Weiterleitungsportal wird erkannt",
mr.ist_aggregator("https://news.google.com/rss/articles/CBMi123?oc=5"))
pruefe("A6 echtes Medium ist kein Aggregator",
not mr.ist_aggregator("https://www.tagesschau.de/ausland/europa/x-100.html"))
# ---------------------------------------------------------------------------
# B. Ein Verlag, ein Name
# ---------------------------------------------------------------------------
print("\nB. Namensvereinheitlichung")
eintraege = [
("https://www.theguardian.com/world/a", "Guardian World"),
("https://www.theguardian.com/world/b", "Guardian UK"),
("https://www.theguardian.com/live/c", "The Guardian"),
("https://www.theguardian.com/live/d", "The Guardian"),
("https://www.france24.com/en/x", "www.france24.com"),
]
namen = mr.namen_je_domain(eintraege)
pruefe("B1 drei Guardian-Varianten ergeben einen Namen",
namen.get("theguardian.com") == "The Guardian", namen.get("theguardian.com"))
pruefe("B2 Hostname als Name faellt auf die Tabelle zurueck",
namen.get("france24.com") == "France24", namen.get("france24.com"))
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"))
# ---------------------------------------------------------------------------
# C. Quellenverzeichnis behaelt die Nummern aus dem Lagebild
# ---------------------------------------------------------------------------
print("\nC. Quellenaufbereitung")
# Nachgebaut nach Lage 53 (EU-Fassung): 25 Eintraege, aber Nummern bis 28.
quellen_roh = [{"nr": n, "name": f"Medium {n}", "url": f"https://medium{n}.de/artikel"}
for n in [1, 2, 3, 20, 22, 24, 26, 27, 28]]
incident = {"sources_json": json.dumps(quellen_roh, ensure_ascii=False)}
sources = rg._prepare_sources(incident)
nummern = [s["nr"] for s in sources]
pruefe("C1 Luecken in der Nummerierung bleiben erhalten",
nummern == [1, 2, 3, 20, 22, 24, 26, 27, 28], nummern)
pruefe("C2 hoechste Nummer ueberlebt die Aufbereitung", max(nummern) == 28, max(nummern))
# Buchstaben-Suffixe und kaputte Nummern duerfen nichts umwerfen
gemischt = {"sources_json": json.dumps([
{"nr": "7a", "name": "A", "url": "https://a.de/1"},
{"nr": None, "name": "B", "url": "https://b.de/1"},
{"nr": 3, "name": "C", "url": "https://c.de/1"},
], ensure_ascii=False)}
gemischte_nrn = [s["nr"] for s in rg._prepare_sources(gemischt)]
pruefe("C3 Suffix wird zur Zahl, fehlende Nummer bekommt die Position",
all(isinstance(n, int) for n in gemischte_nrn) and 7 in gemischte_nrn,
gemischte_nrn)
# Namen im Verzeichnis vereinheitlicht
mehrfach = {"sources_json": json.dumps([
{"nr": 1, "name": "Guardian World", "url": "https://www.theguardian.com/a"},
{"nr": 2, "name": "The Guardian", "url": "https://www.theguardian.com/b"},
{"nr": 3, "name": "The Guardian", "url": "https://www.theguardian.com/c"},
], ensure_ascii=False)}
namen_liste = {s["name"] for s in rg._prepare_sources(mehrfach)}
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
# ---------------------------------------------------------------------------
print("\nD. Quellenstatistik")
artikel = [
{"source": "France24", "source_url": "https://www.france24.com/en/1", "language": "en"},
{"source": "France24", "source_url": "https://www.france24.com/en/2", "language": "en"},
{"source": "El País", "source_url": "https://english.elpais.com/spain/1.html", "language": "de"},
{"source": "tagesschau", "source_url": "https://www.tagesschau.de/1.html", "language": "de"},
{"source": "n-tv", "source_url": "https://www.n-tv.de/1.html", "language": "de"},
]
zitiert = rg._prepare_sources({"sources_json": json.dumps([
{"nr": 1, "name": "France24", "url": "https://www.france24.com/en/1"},
{"nr": 2, "name": "France24", "url": "https://www.france24.com/en/2"},
{"nr": 3, "name": "El País", "url": "https://english.elpais.com/spain/1.html"},
{"nr": 4, "name": "tagesschau", "url": "https://www.tagesschau.de/1.html"},
], ensure_ascii=False)})
stats = rg._prepare_source_stats(zitiert, artikel)
summe = sum(s["count"] for s in stats)
pruefe("D1 Summe der Belege entspricht dem Verzeichnis", summe == len(zitiert), (summe, len(zitiert)))
pruefe("D2 zwei France24-Belege ergeben eine Zeile",
[s for s in stats if s["name"] == "France24"][0]["count"] == 2, stats)
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)
pruefe("D5 Hinweis nutzt echte Umlaute", "zaehlt" not in notiz and "zählt" in notiz, notiz)
# ---------------------------------------------------------------------------
# E. Das Template druckt die Quellennummer, nicht die Zeilennummer
# ---------------------------------------------------------------------------
print("\nE. Ausgabe im Bericht")
env = Environment(loader=FileSystemLoader(str(rg.TEMPLATE_DIR)))
template = env.get_template("report.html")
html = template.render(
incident={"title": "Testlage", "type": "adhoc"},
incident_type_label="Live-Monitoring",
report_date="01.08.2026, 20:39 Uhr",
creator="test@aegis-sight.de",
logo_base64="",
executive_summary="",
zusammenfassung_title="Neueste Entwicklungen",
sections={"quellen"},
scope="report",
lagebild_html="",
lagebild_timestamp="",
sources=sources,
fact_checks=[],
source_stats=[{"name": "Medium 28", "count": 1, "languages": "DE"}],
source_stats_note="Hinweistext zur Prüfung",
timeline=[],
articles=[],
meta={},
include_branding=True,
)
zeilen = [z for z in html.splitlines() if "medium28.de" in z]
pruefe("E1 letzter Eintrag wird als 28 gedruckt, nicht als 9",
zeilen and ">28<" in zeilen[0] and ">9<" not in zeilen[0], zeilen[:1])
pruefe("E2 Statistikspalte heisst Belege", "<th>Belege</th>" in html)
pruefe("E3 Hinweis unter der Statistik erscheint", "Hinweistext zur Prüfung" in html)
pruefe("E4 alle Eintraege erscheinen, nichts wird gekappt",
all(f"medium{n}.de" in html for n in [1, 2, 3, 20, 22, 24, 26, 27, 28]))
print("\nErgebnis: " + str(ok) + " bestanden, " + str(fail) + " fehlgeschlagen")
sys.exit(1 if fail else 0)