Fix broken source links caused by LLM-generated letter suffixes (e.g. 1383a)

The LLM occasionally generates source references with letter suffixes
(e.g. [1383a], [1396b]) despite being instructed not to. This caused
broken links because the sources array only contained integer nr values.

Backend: Add _sanitize_sources() to strip letter suffixes after parsing
and deduplicate, preferring entries with valid URLs.

Frontend: Add fallback in citation renderer - when a suffix reference
like [1383a] has no matching source with URL, fall back to the base
number [1383].

Also cleaned up 99 broken suffix entries and 44 suffix references in
the Irankonflikt incident (ID 6) database records.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Dieser Commit ist enthalten in:
Claude Dev
2026-03-23 23:47:02 +01:00
Ursprung a136e0625f
Commit f60edb42f7
2 geänderte Dateien mit 57 neuen und 2 gelöschten Zeilen

Datei anzeigen

@@ -242,6 +242,7 @@ class AnalyzerAgent:
result, usage = await call_claude(prompt) result, usage = await call_claude(prompt)
analysis = self._parse_response(result) analysis = self._parse_response(result)
if analysis: if analysis:
analysis = self._sanitize_sources(analysis)
logger.info(f"Erstanalyse abgeschlossen: {len(analysis.get('sources', []))} Quellen referenziert") logger.info(f"Erstanalyse abgeschlossen: {len(analysis.get('sources', []))} Quellen referenziert")
return analysis, usage return analysis, usage
except Exception as e: except Exception as e:
@@ -303,6 +304,8 @@ class AnalyzerAgent:
try: try:
result, usage = await call_claude(prompt) result, usage = await call_claude(prompt)
analysis = self._parse_response(result) analysis = self._parse_response(result)
if analysis:
analysis = self._sanitize_sources(analysis)
if analysis and self._all_previous_sources: if analysis and self._all_previous_sources:
# Merge: alte Quellen beibehalten, neue hinzufuegen # Merge: alte Quellen beibehalten, neue hinzufuegen
returned_sources = analysis.get("sources", []) returned_sources = analysis.get("sources", [])
@@ -325,6 +328,51 @@ class AnalyzerAgent:
logger.error(f"Inkrementelle Analyse-Fehler: {e}") logger.error(f"Inkrementelle Analyse-Fehler: {e}")
return None, None return None, None
def _sanitize_sources(self, analysis: dict) -> dict:
"""Entfernt Buchstaben-Suffixe aus Quellennummern (z.B. '1383a' -> 1383).
Das LLM erzeugt trotz Anweisung gelegentlich Suffix-Nummern.
Diese werden hier auf die Basisnummer normalisiert.
Duplikate werden entfernt, wobei Eintraege mit URL bevorzugt werden.
"""
sources = analysis.get("sources", [])
if not sources:
return analysis
cleaned = {}
suffix_count = 0
for s in sources:
nr = s.get("nr", "")
nr_str = str(nr)
# Prüfe auf Buchstaben-Suffix (z.B. "1383a", "1383b")
m = re.match(r"^(\d+)[a-z]$", nr_str)
if m:
base_nr = int(m.group(1))
suffix_count += 1
# Nur übernehmen wenn Basisnummer noch nicht existiert oder
# dieser Eintrag eine URL hat und der bisherige nicht
if base_nr not in cleaned:
s_copy = dict(s)
s_copy["nr"] = base_nr
cleaned[base_nr] = s_copy
elif s.get("url") and not cleaned[base_nr].get("url"):
s_copy = dict(s)
s_copy["nr"] = base_nr
cleaned[base_nr] = s_copy
else:
nr_int = int(nr) if isinstance(nr, (int, float)) or (isinstance(nr, str) and nr.isdigit()) else nr
if nr_int not in cleaned:
cleaned[nr_int] = s
elif s.get("url") and not cleaned[nr_int].get("url"):
cleaned[nr_int] = s
if suffix_count > 0:
logger.info(f"Quellen-Sanitierung: {suffix_count} Buchstaben-Suffixe entfernt")
analysis["sources"] = sorted(cleaned.values(),
key=lambda s: s.get("nr", 0) if isinstance(s.get("nr"), int) else 9999)
return analysis
def _parse_response(self, response: str) -> dict | None: def _parse_response(self, response: str) -> dict | None:
"""Parst die Claude-Antwort als JSON-Objekt mit robustem Fallback.""" """Parst die Claude-Antwort als JSON-Objekt mit robustem Fallback."""
# Markdown-Code-Fences entfernen # Markdown-Code-Fences entfernen

Datei anzeigen

@@ -444,10 +444,17 @@ const UI = {
html = html.replace(/<\/ul>(<br>)+/g, '</ul>'); html = html.replace(/<\/ul>(<br>)+/g, '</ul>');
html = html.replace(/(<br>){2,}/g, '<br>'); html = html.replace(/(<br>){2,}/g, '<br>');
// Inline-Zitate [1], [2] etc. als klickbare Links rendern // Inline-Zitate [1], [2], [1383a] etc. als klickbare Links rendern
if (sources.length > 0) { if (sources.length > 0) {
html = html.replace(/\[(\d+[a-z]?)\]/g, (match, num) => { html = html.replace(/\[(\d+[a-z]?)\]/g, (match, num) => {
const src = sources.find(s => String(s.nr) === num || Number(s.nr) === Number(num)); // Exakte Suche (auch mit Buchstaben-Suffix)
let src = sources.find(s => String(s.nr) === num || Number(s.nr) === Number(num));
// Fallback: Bei Suffix wie "1383a" auf Basisnummer 1383 zurueckfallen
if ((!src || !src.url) && /[a-z]$/.test(num)) {
const baseNum = num.replace(/[a-z]$/, '');
const baseSrc = sources.find(s => String(s.nr) === baseNum || Number(s.nr) === Number(baseNum));
if (baseSrc && baseSrc.url) src = baseSrc;
}
if (src && src.url) { if (src && src.url) {
return `<a href="${this.escape(src.url)}" target="_blank" rel="noopener" class="citation" title="${this.escape(src.name)}">[${num}]</a>`; return `<a href="${this.escape(src.url)}" target="_blank" rel="noopener" class="citation" title="${this.escape(src.name)}">[${num}]</a>`;
} }