diff --git a/src/agents/analyzer.py b/src/agents/analyzer.py index fcbad50..2468524 100644 --- a/src/agents/analyzer.py +++ b/src/agents/analyzer.py @@ -228,7 +228,7 @@ REGELN: - Jedes Bullet beginnt mit dem Zeitstempel der frühesten belegenden Quelle im Format "[DD.MM. HH:MM]". - Jedes Bullet ENDET mit einer Quellen-Klammer — ZWINGEND. Bullets ohne Klammer werden verworfen. - NEUE Bullets (aus den NEUEN MELDUNGEN): {{M, M}} 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. + - UEBERNOMMENE Bullets aus BISHERIGE ENTWICKLUNGEN: behalten ihre bestehende Klammer KOMPLETT UND UNVERAENDERT, inklusive des Pipe-Zeichens und der URL. Beispiel: {{Reuters|https://reuters.com/article, Rybar|https://t.me/rybar/123}}. NICHT in M-IDs umwandeln, NICHT die URL entfernen, NICHT umformatieren. - 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..."). @@ -241,7 +241,7 @@ REGELN: OUTPUT-FORMAT (ausschliesslich, keine Anführungszeichen, kein Code-Fence, JEDE Zeile beginnt mit "- "): - [DD.MM. HH:MM] Ereignistext neu. {{M}} - [DD.MM. HH:MM] Ereignistext neu mit mehreren Belegen. {{M, M}} -- [DD.MM. HH:MM] Ereignistext aus BISHERIGE ENTWICKLUNGEN. {{Quellenname1, Quellenname2}} +- [DD.MM. HH:MM] Ereignistext aus BISHERIGE ENTWICKLUNGEN. {{Quellenname1|URL1, Quellenname2|URL2}} ...""" @@ -430,21 +430,28 @@ class AnalyzerAgent: """Extrahiert '- [DD.MM. HH:MM] ...'-Zeilen aus der Claude-Antwort. Jeder Bullet MUSS mit einer Quellen-Klammer enden (geschweifte Klammern). - - Items im Format M 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. + Items koennen drei Formen haben, werden alle zu 'Name|URL' normalisiert (URL optional): + - M: Aufloesung gegen new_articles, ergibt 'Name|URL'. + - 'Name|URL': wird uebernommen (Format aus previous_developments). + - 'Name' (ohne URL): bleibt unveraendert, wird als 'Name' gespeichert (Fallback). + + Bullets ohne Klammer oder mit leerer Klammer werden verworfen. + Die URL wird direkt dem belegenden Artikel entnommen (article.source_url) — damit + ist der Klick im Frontend eindeutig auf den belegenden Post, ohne sources_json-Lookup. """ if not text: return [] - articles_by_id: dict[str, str] = {} + # Mapping id -> (name, url) aus new_articles + articles_by_id: dict[str, tuple[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() + url = (a.get("source_url") or "").strip() if name: - articles_by_id[str(aid)] = name + articles_by_id[str(aid)] = (name, url) bullets: list[str] = [] # Dash-Praefix + zweiter Datums-Punkt + optionales Jahr: Claude Haiku laesst diese gelegentlich weg. @@ -455,6 +462,14 @@ class AnalyzerAgent: 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) + def _format_item(name: str, url: str) -> str: + """Formatiert Name + URL zu 'Name|URL' (oder 'Name' wenn URL leer).""" + name = (name or "").strip() + url = (url or "").strip() + # Pipe im Namen ist extrem unwahrscheinlich, aber sicher ersetzen + name = name.replace("|", "/") + return f"{name}|{url}" if url else name + for raw_line in text.splitlines(): line = raw_line.strip() if not line: @@ -473,16 +488,37 @@ class AnalyzerAgent: raw_items = [it.strip() for it in brace_match.group(1).split(",") if it.strip()] resolved: list[str] = [] + seen_keys: set[str] = set() + + def _dedupe_key(name: str) -> str: + return name.strip().lower() + 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) + pair = articles_by_id.get(mid.group(1)) + if pair: + name, url = pair + key = _dedupe_key(name) + if key not in seen_keys: + seen_keys.add(key) + resolved.append(_format_item(name, url)) + elif "|" in it: + # bereits im Name|URL-Format + parts = it.split("|", 1) + name_p = parts[0].strip() + url_p = (parts[1] if len(parts) > 1 else "").strip() + if name_p and not junk_item.match(name_p): + key = _dedupe_key(name_p) + if key not in seen_keys: + seen_keys.add(key) + resolved.append(_format_item(name_p, url_p)) else: - if it not in resolved: + key = _dedupe_key(it) + if key not in seen_keys: + seen_keys.add(key) resolved.append(it) if not resolved: diff --git a/src/static/js/components.js b/src/static/js/components.js index 3c9c97c..ff2db9f 100644 --- a/src/static/js/components.js +++ b/src/static/js/components.js @@ -812,19 +812,29 @@ const UI = { let pillsHtml = ''; - // Primär: {Name1, Name2} am Bullet-Ende + // Primär: {Name1|URL1, Name2|URL2} oder {Name1, Name2} am Bullet-Ende const trailing = trailingNamesRe.exec(rawBody); if (trailing) { rawBody = rawBody.replace(trailingNamesRe, '').trim(); - const names = trailing[1].split(',').map(s => s.trim()).filter(Boolean); + const items = trailing[1].split(',').map(s => s.trim()).filter(Boolean); const seen = new Set(); - pillsHtml = names.map(name => { - const key = normalize(name); + pillsHtml = items.map(item => { + // Split am ersten Pipe: "Name|URL" → Name + URL; ohne Pipe nur Name + const pipeIdx = item.indexOf('|'); + const itemName = pipeIdx >= 0 ? item.slice(0, pipeIdx).trim() : item.trim(); + const itemUrl = pipeIdx >= 0 ? item.slice(pipeIdx + 1).trim() : ''; + if (!itemName) return ''; + const key = normalize(itemName); if (seen.has(key)) return ''; seen.add(key); - if (/^(unbekannt|unknown|n\/a|keine)$/i.test(name)) return ''; - const src = lookupByName(name); - return buildPill(src, name); + if (/^(unbekannt|unknown|n\/a|keine)$/i.test(itemName)) return ''; + // Wenn URL direkt mitgeliefert wurde: eindeutiger Link, keine Kollision mit sources_json moeglich + if (itemUrl) { + return buildPill({ name: itemName, url: itemUrl }, itemName); + } + // Fallback (Legacy-Bullets ohne URL): Name-Lookup in sources_json + const src = lookupByName(itemName); + return buildPill(src, itemName); }).filter(Boolean).join(''); }