fix(bericht): Quellenverzeichnis gibt die Nummern aus dem Lagebild wieder

Der Vergleich zweier Berichte zur selben Lage (Ceuta, 01.08.2026) zeigte in
beiden Fassungen Textverweise, die im gedruckten Verzeichnis nicht auftauchten.
Die EU-Fassung zitierte bis [28] bei 25 aufgefuehrten Quellen, die
Anthropic-Fassung bis [29] bei 22.

Ursache war nicht die Analyse. In der Datenbank passen Text und Liste in beiden
Laeufen exakt zusammen, keine einzige Referenz ohne Eintrag. Das PDF-Template
druckte jedoch die laufende Zeilennummer (loop.index) statt der Quellennummer
aus dem Lagebild (src.nr). Sobald die Nummern Luecken haben, und das ist der
Normalfall, weil nicht jede gesammelte Meldung zitiert wird, verschiebt sich
ab der ersten Luecke jeder Beleg. Lage 53 hat die Nummern 1 bis 20, 22, 24, 26,
27, 28: gedruckt wurde 1 bis 25, aus Quelle 28 wurde Zeile 25.

Behoben:

1. Das Verzeichnis druckt src.nr. Gegenprobe an den echten Daten beider Laeufe:
   vorher fehlten in Lage 53 die Verweise [22] bis [28], jetzt loest jede der
   25 Referenzen auf, in Lage 52 alle 22.

2. Keine Kappung des Verzeichnisses mehr. Im Umfang "Bericht" wurde bei 30
   Eintraegen abgeschnitten, hoehere Referenzen im Text waren damit
   grundsaetzlich unaufloesbar.

3. Die Word-Ausgabe bekommt ueberhaupt erst ein Quellenverzeichnis. Bisher
   verwies der Text dort auf [1], [2], ohne dass die Datei die Nummern
   irgendwo aufloeste.

4. Die Quellenstatistik bezieht sich jetzt auf dieselbe Grundmenge wie das
   Verzeichnis. Vorher zaehlte sie alle gesammelten Artikel (49) und stand
   damit neben einem Verzeichnis mit 25 Eintraegen, was wie eine Aufwertung
   der Quellenbasis wirkte. Die Summe der Spalte Belege entspricht jetzt der
   Zahl der Eintraege, ein Hinweis nennt zusaetzlich die insgesamt
   ausgewerteten Meldungen.

5. Ein Verlag wird unter einem Namen gefuehrt. Bisher trennte der Bericht
   "Guardian UK", "Guardian World" und "The Guardian" oder "tagesschau" und
   "tagesschau.de" in eigene Zeilen und blaehte damit die Vielfaltsstatistik
   auf. Die neue services/media_registry.py bestimmt die Identitaet ueber die
   registrierbare Domain und waehlt den haeufigsten Namen. Aus 20
   Statistikzeilen der EU-Fassung werden so 11 echte Medien. Sie kennt
   ausserdem Weiterleitungsportale und liest die Sprache aus der Adresse,
   sodass english.elpais.com nicht mehr als DE gefuehrt wird.

Neu sind 22 Pruefungen, insgesamt laufen 118 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:41:38 +02:00
Ursprung bf967216de
Commit 58aec2edd5
5 geänderte Dateien mit 515 neuen und 17 gelöschten Zeilen

Datei anzeigen

@@ -19,6 +19,7 @@ from docx.enum.text import WD_ALIGN_PARAGRAPH
from docx.enum.table import WD_TABLE_ALIGNMENT
from config import TIMEZONE, CLAUDE_MODEL_FAST
from services import media_registry
logger = logging.getLogger("osint.report")
@@ -68,29 +69,121 @@ def _get_logo_base64() -> str:
def _prepare_sources(incident: dict) -> list:
"""Quellenverzeichnis aus sources_json parsen."""
"""Quellenverzeichnis aus sources_json parsen.
Die Nummer aus dem Lagebild (nr) ist die Identitaet des Eintrags, sie wird
hier gesichert und die Liste danach sortiert. Der Anzeigename wird je
Domain vereinheitlicht, damit derselbe Verlag nicht unter mehreren Namen
im Verzeichnis steht.
"""
raw = incident.get("sources_json")
if not raw:
return []
try:
return json.loads(raw) if isinstance(raw, str) else raw
roh = json.loads(raw) if isinstance(raw, str) else raw
except (json.JSONDecodeError, TypeError):
return []
if not isinstance(roh, list):
return []
eintraege = [s for s in roh if isinstance(s, dict)]
namen = media_registry.namen_je_domain(
[(s.get("url") or "", s.get("name") or "") for s in eintraege]
)
aufbereitet = []
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:
kopie["name"] = namen.get(domain) or kopie.get("name") or media_registry.name_aus_domain(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 _prepare_source_stats(articles: list) -> list:
"""Quellenstatistik: Artikel pro Quelle + Sprachen."""
source_map = defaultdict(lambda: {"count": 0, "langs": set()})
for art in articles:
name = art.get("source") or "Unbekannt"
source_map[name]["count"] += 1
source_map[name]["langs"].add((art.get("language") or "de").upper())
def _als_nummer(wert, ersatz: int) -> int:
"""Quellennummer als ganze Zahl, Buchstaben-Suffixe werden abgeschnitten."""
if isinstance(wert, int):
return wert
text = str(wert or "").strip()
m = re.match(r"^(\d+)", text)
return int(m.group(1)) if m else ersatz
def _prepare_source_stats(sources: list, articles: list) -> list:
"""Quellenstatistik ueber die im Lagebild zitierten Belege.
Frueher zaehlte die Statistik alle gesammelten Artikel der Lage, waehrend
das Quellenverzeichnis darunter nur die zitierten Quellen auflistete. Die
beiden Tabellen widersprachen sich damit in jedem Bericht (im Ceuta-Fall
49 gezaehlte Artikel gegen 25 aufgefuehrte Quellen). Jetzt bezieht sich die
Statistik auf dieselbe Grundmenge wie das Verzeichnis: die Summe der Spalte
Belege entspricht genau der Zahl der Eintraege.
Gezaehlt wird je Domain, nicht je Name, damit mehrere Feeds desselben
Verlags eine Zeile ergeben. Die Sprache kommt aus den Artikeln derselben
Domain, eine eindeutige Sprachangabe in der Adresse hat Vorrang.
"""
sprachen_je_domain: dict[str, set] = defaultdict(set)
for art in articles or []:
domain = media_registry.registrable_domain(art.get("source_url") or "")
if not domain:
continue
aus_url = media_registry.sprache_aus_url(art.get("source_url") or "")
sprachen_je_domain[domain].add(aus_url or (art.get("language") or "de").upper())
stats_map: dict[str, dict] = {}
for s in sources or []:
url = s.get("url") or ""
domain = media_registry.registrable_domain(url)
schluessel = domain or (s.get("name") or "Unbekannt")
eintrag = stats_map.setdefault(
schluessel,
{"name": s.get("name") or media_registry.name_aus_domain(domain), "count": 0, "langs": set()},
)
eintrag["count"] += 1
aus_url = media_registry.sprache_aus_url(url)
if aus_url:
eintrag["langs"].add(aus_url)
elif domain in sprachen_je_domain:
eintrag["langs"].update(sprachen_je_domain[domain])
stats = []
for name, data in sorted(source_map.items(), key=lambda x: -x[1]["count"]):
stats.append({"name": name, "count": data["count"], "languages": ", ".join(sorted(data["langs"]))})
for data in sorted(stats_map.values(), key=lambda d: (-d["count"], d["name"])):
stats.append({
"name": data["name"],
"count": data["count"],
"languages": ", ".join(sorted(data["langs"])),
})
return stats
def _source_stats_note(sources: list, articles: list) -> str:
"""Erlaeuterung unter der Quellenstatistik.
Haelt fest, worauf sich die Tabelle bezieht und wie viele Meldungen
insgesamt ausgewertet wurden. Ohne diesen Satz wirkte die frueher groessere
Artikelzahl wie eine Aufwertung der Quellenbasis.
"""
zitiert = len(sources or [])
gesamt = len(articles or [])
medien = len({
media_registry.registrable_domain(a.get("source_url") or "")
for a in (articles or [])
if media_registry.registrable_domain(a.get("source_url") or "")
})
if not gesamt:
return f"Die Tabelle zählt die {zitiert} im Lagebild zitierten Belege, gruppiert nach Medium."
return (
f"Die Tabelle zählt die {zitiert} im Lagebild zitierten Belege, gruppiert nach Medium. "
f"Ausgewertet wurden insgesamt {gesamt} Meldungen aus {medien} Medien."
)
def _prepare_fact_checks(fact_checks: list, lang_iso: str = "de") -> list:
"""Faktenchecks mit Label aufbereiten."""
labels = _fc_labels(lang_iso)
@@ -831,9 +924,13 @@ async def generate_pdf(
_markdown_to_html(bericht_summary), all_sources
),
lagebild_timestamp=(incident.get("updated_at") or "")[:16].replace("T", " "),
sources=_prepare_sources(incident)[:30] if scope == "report" else _prepare_sources(incident),
# Keine Kappung mehr. Ein bei 30 abgeschnittenes Verzeichnis machte
# jede hoehere Referenz im Text unaufloesbar, der Bericht wies also
# Belege aus, die er selbst nicht zeigte.
sources=all_sources,
fact_checks=_prepare_fact_checks(fact_checks),
source_stats=_prepare_source_stats(articles)[:20] if scope == "report" else _prepare_source_stats(articles),
source_stats=_prepare_source_stats(all_sources, articles),
source_stats_note=_source_stats_note(all_sources, articles),
timeline=_prepare_timeline(articles) if scope == "full" else [],
articles=articles if scope == "full" else [],
meta=meta,
@@ -1043,7 +1140,7 @@ async def generate_docx(
if "quellen" in sections:
# --- Quellenstatistik ---
source_stats = _prepare_source_stats(articles)
source_stats = _prepare_source_stats(all_sources, articles)
if source_stats:
doc.add_heading("Quellenstatistik", level=1)
table = doc.add_table(rows=1, cols=3)
@@ -1051,7 +1148,7 @@ async def generate_docx(
table.alignment = WD_TABLE_ALIGNMENT.CENTER
hdr = table.rows[0].cells
hdr[0].text = "Quelle"
hdr[1].text = "Artikel"
hdr[1].text = "Belege"
hdr[2].text = "Sprache"
for cell in hdr:
for p in cell.paragraphs:
@@ -1062,6 +1159,40 @@ async def generate_docx(
row[0].text = stat["name"]
row[1].text = str(stat["count"])
row[2].text = stat["languages"]
hinweis = doc.add_paragraph()
hinweis_run = hinweis.add_run(_source_stats_note(all_sources, articles))
hinweis_run.font.size = Pt(8)
hinweis_run.font.color.rgb = RGBColor(0x66, 0x66, 0x66)
# --- Quellenverzeichnis ---
# Fehlte in der Word-Ausgabe komplett: Der Text verwies auf [1], [2],
# ohne dass die Datei die Nummern irgendwo aufloeste. Die Nummer ist
# die aus dem Lagebild (nr), NICHT die laufende Zeilennummer.
if all_sources:
doc.add_heading("Quellenverzeichnis", level=1)
table = doc.add_table(rows=1, cols=3)
table.style = 'Table Grid'
table.alignment = WD_TABLE_ALIGNMENT.CENTER
hdr = table.rows[0].cells
hdr[0].text = "#"
hdr[1].text = "Quelle"
hdr[2].text = "URL"
for cell in hdr:
for p in cell.paragraphs:
p.runs[0].font.bold = True
p.runs[0].font.size = Pt(9)
for src in all_sources:
row = table.add_row().cells
row[0].text = str(src.get("nr", ""))
row[1].text = src.get("name") or src.get("title") or ""
url = src.get("url") or ""
if url:
zelle = row[2].paragraphs[0]
_add_docx_hyperlink(zelle, url, url)
for cell in row:
for p in cell.paragraphs:
for run in p.runs:
run.font.size = Pt(8)
if "timeline" in sections:
# --- Artikelverzeichnis ---