Live-Monitoring: Quellen-IDs deterministisch aufloesen, Bias-Markierung raus

Aenderung am Grund-Mechanismus: LLM liefert pro Bullet die Meldungs-IDs
im Format {M<ID>, M<ID>}, das Backend loest die IDs gegen new_articles
zu Quellen-Namen auf und schreibt {Reuters, Rybar} in die DB. Uebernommene
Bullets aus previous_developments behalten ihre bestehende {Name}-Klammer.

Bullets ohne Quellen-Klammer oder mit unaufloesbarer Klammer werden vom
Parser verworfen — dadurch existiert "Keine Quelle" nicht mehr.

Frontend: Bias-Farbcodierung (pro-RU, staatsnah) + zugehoerige Heuristik
_classifyBias/_biasLabel entfernt. Kein Sonderfall-Rendering fuer leere
Pills mehr.
Dieser Commit ist enthalten in:
claude-dev
2026-04-18 20:50:46 +00:00
Ursprung 5c95d85871
Commit 89ab158202
3 geänderte Dateien mit 64 neuen und 60 gelöschten Zeilen

Datei anzeigen

@@ -226,9 +226,11 @@ Extrahiere aus den NEUEN Meldungen konkrete Ereignisse und aktualisiere die List
REGELN:
- Jedes Bullet = EIN konkretes Ereignis (1-2 Sätze, faktenbasiert). Keine Themen-Zusammenfassungen.
- Jedes Bullet beginnt mit dem Zeitstempel der frühesten belegenden Quelle im Format "[DD.MM. HH:MM]".
- Jedes Bullet endet mit den Quellen-Namen in geschweiften Klammern, kommasepariert. Beispiel: "{{Reuters, Rybar}}". Verwende EXAKT die Schreibweise aus der "Quelle:"-Zeile der Meldung (z.B. "Der Tagesspiegel", nicht "Tagesspiegel"). Keine Abkuerzungen, keine Umformulierungen.
- Wenn mehrere Meldungen dasselbe Ereignis betreffen: EIN Bullet, Zeitstempel = frühester Zeitpunkt, ALLE belegenden Quellen-Namen in den Klammern.
- Bestehende Bullets aus BISHERIGE ENTWICKLUNGEN sinngemäß übernehmen, NICHT umformulieren. Nur entfernen, wenn sie durch neue Meldungen nachweislich überholt sind oder die 8-Bullet-Grenze überschritten wird (dann älteste fallen raus). Wenn einem uebernommenen Bullet die Quellen-Klammer fehlt (Altformat), ergaenze sie anhand der URSPRUENGLICHEN Beleg-Meldung so gut moeglich; ansonsten "{{Unbekannt}}".
- Jedes Bullet ENDET mit einer Quellen-Klammer — ZWINGEND. Bullets ohne Klammer werden verworfen.
- NEUE Bullets (aus den NEUEN MELDUNGEN): {{M<ID1>, M<ID2>}} mit den ganzzahligen IDs aus der "ID:"-Zeile der belegenden Meldung(en). Beispiele: {{M42}} oder {{M42, M17}}.
- UEBERNOMMENE Bullets aus BISHERIGE ENTWICKLUNGEN: behalten ihre bestehende {{Name1, Name2}}-Klammer UNVERAENDERT. NICHT in M-IDs umwandeln.
- Wenn mehrere Meldungen dasselbe Ereignis belegen: EIN Bullet, Zeitstempel = frühester Zeitpunkt, ALLE IDs in der Klammer.
- Bestehende Bullets aus BISHERIGE ENTWICKLUNGEN sinngemäß übernehmen, NICHT umformulieren. Nur entfernen, wenn sie durch neue Meldungen nachweislich überholt sind oder die 8-Bullet-Grenze überschritten wird (dann älteste fallen raus). Wenn einem uebernommenen Bullet die Quellen-Klammer fehlt (Altformat): Bullet VERWERFEN und nicht in die neue Liste uebernehmen.
- Wenn eine Quelle eine erkennbare politische Ausrichtung hat (z.B. pro-russisch, staatsnah, rechtsextrem), im Bullet-Text erwähnen ("laut pro-russischem Telegram-Kanal Rybar...").
- Neutral und sachlich — keine Wertungen oder Spekulationen.
- KEINE Gedankenstriche (—, –) — stattdessen Kommas, Doppelpunkte oder neue Sätze.
@@ -237,8 +239,9 @@ REGELN:
- Wenn aus den neuen Meldungen kein neues Ereignis extrahierbar ist: BISHERIGE ENTWICKLUNGEN unverändert zurückgeben.
OUTPUT-FORMAT (ausschliesslich, keine Anführungszeichen, kein Code-Fence):
- [DD.MM. HH:MM] Erster Ereignistext. {{Quellenname1}}
- [DD.MM. HH:MM] Zweiter Ereignistext. {{Quellenname1, Quellenname2}}
- [DD.MM. HH:MM] Ereignistext neu. {{M<ID>}}
- [DD.MM. HH:MM] Ereignistext neu mit mehreren Belegen. {{M<ID1>, M<ID2>}}
- [DD.MM. HH:MM] Ereignistext aus BISHERIGE ENTWICKLUNGEN. {{Quellenname1, Quellenname2}}
..."""
@@ -412,7 +415,7 @@ class AnalyzerAgent:
logger.error(f"Latest-Developments-Fehler: {e}")
return (prev or None), None
bullets = self._parse_latest_developments(result)
bullets = self._parse_latest_developments(result, new_articles)
if not bullets:
logger.info("Latest-Developments: keine Bullets geparst, behalte bisherigen Stand")
return (prev or None), usage
@@ -423,21 +426,67 @@ class AnalyzerAgent:
return output, usage
@staticmethod
def _parse_latest_developments(text: str) -> list[str]:
"""Extrahiert '- [DD.MM. HH:MM] ...'-Zeilen aus der Claude-Antwort."""
def _parse_latest_developments(text: str, new_articles: list[dict] | None = None) -> list[str]:
"""Extrahiert '- [DD.MM. HH:MM] ...'-Zeilen aus der Claude-Antwort.
Jeder Bullet MUSS mit einer Quellen-Klammer enden (geschweifte Klammern).
- Items im Format M<ID> werden gegen new_articles aufgeloest zu Quellen-Namen.
- Items ohne ID-Pattern werden als bereits-aufgeloeste Namen uebernommen.
- Bullets ohne Klammer oder mit leerer Klammer werden verworfen.
"""
if not text:
return []
articles_by_id: dict[str, str] = {}
if new_articles:
for a in new_articles:
aid = a.get("id")
if aid is not None:
name = (a.get("source") or "").strip()
if name:
articles_by_id[str(aid)] = name
bullets: list[str] = []
bullet_re = re.compile(r"^\s*[-*•]\s*\[(\d{1,2}\.\d{1,2}\.(?:\d{2,4})?\s+\d{1,2}:\d{2})\]\s*(.+?)\s*$")
trailing_braces = re.compile(r"\{([^{}]+)\}\s*\.?\s*$")
id_item = re.compile(r"^[M#]\s*(\d+)$", re.IGNORECASE)
junk_item = re.compile(r"^(unbekannt|unknown|n/?a|keine|keine quelle|tba)$", re.IGNORECASE)
for raw_line in text.splitlines():
line = raw_line.strip()
if not line:
continue
m = bullet_re.match(line)
if m:
ts = m.group(1)
body = m.group(2).rstrip()
bullets.append(f"- [{ts}] {body}")
if not m:
continue
ts = m.group(1)
body = m.group(2).rstrip()
brace_match = trailing_braces.search(body)
if not brace_match:
logger.debug(f"Bullet ohne Quellen-Klammer verworfen: {line[:80]}")
continue
raw_items = [it.strip() for it in brace_match.group(1).split(",") if it.strip()]
resolved: list[str] = []
for it in raw_items:
if junk_item.match(it):
continue
mid = id_item.match(it)
if mid:
name = articles_by_id.get(mid.group(1))
if name and name not in resolved:
resolved.append(name)
else:
if it not in resolved:
resolved.append(it)
if not resolved:
logger.debug(f"Bullet mit leerer/unaufloesbarer Quellen-Klammer verworfen: {line[:80]}")
continue
body_clean = body[: brace_match.start()].rstrip()
bullets.append(f"- [{ts}] {body_clean} {{{', '.join(resolved)}}}")
return bullets
def _sanitize_sources(self, analysis: dict) -> dict:

Datei anzeigen

@@ -1096,12 +1096,6 @@ a:hover {
min-width: 0;
}
.dev-sources-empty {
color: var(--text-disabled);
font-size: 11px;
font-style: italic;
}
.dev-source-pill {
display: inline-flex;
align-items: center;
@@ -1127,26 +1121,6 @@ a.dev-source-pill:hover {
color: var(--text-primary);
}
.dev-bias {
font-size: 9px;
padding: 1px 4px;
border-radius: 2px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.3px;
line-height: 1.4;
}
.dev-bias-pro-ru {
background: var(--tint-error);
color: var(--error);
}
.dev-bias-staatsnah {
background: var(--tint-warning);
color: var(--warning);
}
.dev-time {
color: var(--text-tertiary);
font-size: 11px;

Datei anzeigen

@@ -788,12 +788,10 @@ const UI = {
const buildPill = (src, fallbackName) => {
const displayName = src ? (src.name || fallbackName) : fallbackName;
const esc = this.escape(displayName);
const biasClass = this._classifyBias(displayName);
const biasHtml = biasClass ? `<span class="dev-bias dev-bias-${biasClass}">${this._biasLabel(biasClass)}</span>` : '';
if (src && src.url) {
return `<a href="${this.escape(src.url)}" target="_blank" rel="noopener" class="dev-source-pill" title="${esc}">${esc}${biasHtml}</a>`;
return `<a href="${this.escape(src.url)}" target="_blank" rel="noopener" class="dev-source-pill" title="${esc}">${esc}</a>`;
}
return `<span class="dev-source-pill" title="${esc}">${esc}${biasHtml}</span>`;
return `<span class="dev-source-pill" title="${esc}">${esc}</span>`;
};
const cards = bulletLines.map(line => {
@@ -842,9 +840,7 @@ const UI = {
}
const cleanBody = this.escape(rawBody.trim());
const sourcesHtml = pillsHtml
? `<span class="dev-sources">${pillsHtml}</span>`
: `<span class="dev-sources dev-sources-empty">Keine Quelle</span>`;
const sourcesHtml = pillsHtml ? `<span class="dev-sources">${pillsHtml}</span>` : '<span class="dev-sources"></span>';
const timeHtml = `<span class="dev-time" title="${this.escape(date + ' ' + time)}">${this.escape(time)} \u00b7 ${this.escape(date)}</span>`;
return `<div class="dev-bullet"><div class="dev-bullet-head">${sourcesHtml}${timeHtml}</div><div class="dev-body">${cleanBody}</div></div>`;
@@ -853,21 +849,6 @@ const UI = {
return `<div class="dev-list">${cards.join('')}</div>`;
},
_classifyBias(name) {
if (!name) return null;
const n = name.toLowerCase();
const proRu = ['rybar', 'sputnik', 'tass', 'ria novosti', 'ria.ru', 'tsargrad', 'readovka', 'pravda', 'russia today', 'rt.com', 'rt deutsch'];
const staatsnah = ['cctv', 'global times', 'xinhua', "people's daily", 'press tv', 'irna', 'kcna'];
if (proRu.some(k => n.includes(k))) return 'pro-ru';
if (staatsnah.some(k => n.includes(k))) return 'staatsnah';
return null;
},
_biasLabel(cls) {
if (cls === 'pro-ru') return 'pro-RU';
if (cls === 'staatsnah') return 'staatsnah';
return cls;
},
renderSummary(summary, sourcesJson, incidentType) {
if (!summary) return '<span style="color:var(--text-tertiary);">Noch keine Zusammenfassung.</span>';