feat(public-mood): Stimmungs-Kachel aus Foren-Quellen
Eigene Pipeline-Stufe nach factcheck, vor summary, die Foren-Artikel (media_type='forum') zu einer Themen-Zusammenfassung verarbeitet. Wird als separate Dashboard-Kachel "Öffentliche Stimmung" angezeigt — getrennt von Lagebild und Faktencheck, damit anonyme Forenposts nicht mit belegter Faktenlage verwechselt werden. - DB-Migration: incidents.public_mood (TEXT) + public_mood_updated_at (TS). - pipeline_tracker: neuer Pipeline-Step "public_mood" (DE/EN-Labels). - analyzer.generate_public_mood: Haiku-Call der Foren-Beitraege pro Quelle gruppiert und 3-6 thematische Bullets erzeugt, mit expliziter Quellen- Herkunft pro Bullet. Bei zu duennem Material gibt's keinen Output. - orchestrator: neuer Schritt zwischen Factcheck und Summary. Laedt alle Foren-Artikel der Lage (via JOIN auf sources), uebergibt sie an den Stimmungs-Agent, speichert den Markdown-Text in incidents.public_mood. - Topic-Filter (analyzer.filter_relevant_articles) markiert Foren-Quellen mit [FORUM]-Tag und bekommt im Prompt die Regel, Foren-Artikel weicher zu bewerten (Lage-Keyword im Titel reicht). Sie sollen in der Stimmungs- Kachel landen, nicht voreilig verworfen werden. - IncidentResponse-Modell: public_mood/public_mood_updated_at ergaenzt. - Frontend: neuer Tab "Öffentliche Stimmung" (nur sichtbar wenn Inhalt da), eigene Kachel mit Warn-Hinweis "keine Faktenlage". UI.renderPublicMood als einfacher Bullet-Renderer. - dashboard.html Cache-Buster fuer components.js + app.js gebumpt. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Dieser Commit ist enthalten in:
@@ -260,6 +260,7 @@ REGELN:
|
||||
- Eine Meldung gilt auch dann als relevant, wenn sie das Thema aus einer gegnerischen/kritischen Perspektive behandelt — es geht um thematische Zugehörigkeit, nicht um Ausrichtung.
|
||||
- FREMDSPRACHIGE QUELLEN (CJK, Arabisch, Hebräisch, Kyrillisch): Wo verfügbar steht eine "Übersetzung:"-Zeile unter der Originalüberschrift. NUTZE die Übersetzung für deine Bewertung. Verwirf einen fremdsprachigen Artikel NICHT pauschal aus Sicherheit, wenn die Übersetzung das Lagethema sichtbar berührt — wende dieselben Maßstäbe an wie auf englische Artikel.
|
||||
- Im Zweifel bei lateinisch geschriebenen Quellen: NICHT relevant. Im Zweifel bei nicht-lateinischen Quellen mit übersetzter, thematisch passender Überschrift: relevant.
|
||||
- FOREN-QUELLEN ([FORUM]-Tag hinter dem Quellennamen, z.B. 5ch, Hatena, Note): WEICHER bewerten. Sie liefern keine Faktenlage, sondern Stimmungsmaterial fuer eine separate Kachel. Wenn das Lage-Keyword im Thread-Titel oder in der ersten Zeile des Inhalts vorkommt UND der Beitrag nicht offensichtlich off-topic ist (Hobby, Sport ohne Bezug, reine Werbung), DURCHLASSEN. Im Zweifel bei Foren-Quellen: relevant.
|
||||
|
||||
Antworte AUSSCHLIESSLICH als JSON-Objekt — KEINE Erklärung, KEINE Einleitung:
|
||||
{{"relevant_ids": [1, 3, 7]}}"""
|
||||
@@ -530,7 +531,11 @@ class AnalyzerAgent:
|
||||
# Pre-Topic-Translation für fremdsprachige Headlines (gesetzt vom Orchestrator)
|
||||
headline_en = article.get("headline_en_for_topic")
|
||||
content_en = article.get("content_en_for_topic")
|
||||
lines.append(f"[{i}] Quelle: {source}")
|
||||
# Foren-Quellen explizit markieren, damit Haiku sie weicher bewertet
|
||||
# (Stimmungs-Material, nicht Faktenlage — eigener Filter-Modus im Prompt)
|
||||
is_forum = (article.get("media_type") or "").lower() == "forum"
|
||||
source_label = f"{source} [FORUM]" if is_forum else source
|
||||
lines.append(f"[{i}] Quelle: {source_label}")
|
||||
lines.append(f" Überschrift: {headline}")
|
||||
if headline_en and headline_en.strip().lower() != (headline or "").strip().lower():
|
||||
lines.append(f" Übersetzung: {headline_en}")
|
||||
@@ -667,6 +672,101 @@ class AnalyzerAgent:
|
||||
logger.info(f"Latest-Developments: {len(bullets)} Bullets aus Lagebild generiert")
|
||||
return output, usage
|
||||
|
||||
async def generate_public_mood(
|
||||
self,
|
||||
title: str,
|
||||
description: str,
|
||||
forum_articles: list[dict],
|
||||
output_language: str = "Deutsch",
|
||||
) -> tuple[str | None, ClaudeUsage | None]:
|
||||
"""Generiert die Kachel 'Öffentliche Stimmung' aus Foren-Quellen.
|
||||
|
||||
Eingabe: Artikel mit media_type='forum' (5ch-Threads, Hatena-Bookmarks,
|
||||
Note-Trending-Posts etc.). Ausgabe: 3-6 Markdown-Bullets, jeder Bullet
|
||||
fasst ein dominantes Thema/eine Bruchlinie der Diskussion zusammen und
|
||||
nennt explizit die Quellen-Herkunft (z.B. "Auf 5ch /seiji/ ueberwiegen
|
||||
ablehnende Stimmen ...").
|
||||
|
||||
WICHTIG: Das ist Stimmungsmaterial, NICHT Faktenlage. Der Prompt weist
|
||||
Claude explizit an, Eigenaussagen aus Foren nicht als Fakt zu zitieren.
|
||||
|
||||
Returns: (markdown_text, usage) oder (None, usage) bei leerer/kaputter
|
||||
Antwort. Bei keinen Foren-Artikeln: (None, None).
|
||||
"""
|
||||
if not forum_articles:
|
||||
return None, None
|
||||
|
||||
from config import CLAUDE_MODEL_FAST
|
||||
|
||||
# Pro Quelle gruppieren, damit Claude die Herkunft kennt
|
||||
by_source: dict[str, list[dict]] = {}
|
||||
for a in forum_articles:
|
||||
src = (a.get("source") or "Forum (unbekannt)").strip()
|
||||
by_source.setdefault(src, []).append(a)
|
||||
|
||||
# Artikel-Block bauen, kompakt aber mit Herkunft
|
||||
lines: list[str] = []
|
||||
for src, items in by_source.items():
|
||||
lines.append(f"\n=== Quelle: {src} ({len(items)} Beitrag/-e) ===")
|
||||
for it in items[:15]: # max 15 pro Quelle, sonst sprengt das den Prompt
|
||||
headline = it.get("headline_de") or it.get("headline_en_for_topic") or it.get("headline", "")
|
||||
content = (
|
||||
it.get("content_de")
|
||||
or it.get("content_en_for_topic")
|
||||
or it.get("content_original")
|
||||
or ""
|
||||
)
|
||||
lines.append(f"- {headline[:200]}")
|
||||
if content:
|
||||
lines.append(f" {content[:300]}")
|
||||
articles_block = "\n".join(lines)
|
||||
|
||||
prompt = f"""Du bist ein OSINT-Analyst. Aus den folgenden ANONYMEN FOREN-/COMMUNITY-BEITRAEGEN sollst du das Stimmungsbild der oeffentlichen Online-Diskussion fuer eine Lage extrahieren.
|
||||
|
||||
LAGE: {title}
|
||||
KONTEXT: {description}
|
||||
|
||||
FOREN-BEITRAEGE (gruppiert nach Quelle):
|
||||
{articles_block}
|
||||
|
||||
AUFGABE:
|
||||
Erstelle eine kompakte Themen-Zusammenfassung in {output_language}: 3-6 Markdown-Bullet-Points, jeder Bullet fasst ein dominantes Thema, eine Forderung oder eine Bruchlinie der Diskussion zusammen. Pro Bullet 1-3 Saetze.
|
||||
|
||||
REGELN:
|
||||
- DIES IST KEINE FAKTENLAGE. Du fasst zusammen, wie online diskutiert wird, nicht was wahr ist.
|
||||
- Quellen-Herkunft je Bullet EXPLIZIT nennen ("auf 5ch /seiji/ ueberwiegen ablehnende Reaktionen...", "Hatena-Kommentare betonen ueberwiegend ...", "Note-Autoren schreiben ueberwiegend ...").
|
||||
- KEINE Eigenaussagen aus Forenposts als Faktenbehauptung uebernehmen.
|
||||
- KEINE Klarnamen, persoenliche Daten oder Beleidigungen Dritter zitieren.
|
||||
- Bei klaren Pro-/Contra-Lagern beide Seiten beschreiben.
|
||||
- Wenn das Material zu duenn oder off-topic ist, gib explizit "Material zu duenn fuer Stimmungsbild" zurueck statt zu spekulieren.
|
||||
- Markdown: nur "- " Bullets, keine Ueberschriften, kein Fettdruck, keine Inline-Quellenverweise [1].
|
||||
- KEINE Gedankenstriche (—, –) verwenden — stattdessen Kommas, Doppelpunkte oder neue Saetze.
|
||||
- Verwende IMMER echte UTF-8-Umlaute (ä, ö, ü, ß) — NIEMALS Umschreibungen (ae, oe, ue, ss).
|
||||
|
||||
Antworte AUSSCHLIESSLICH mit dem Markdown-Text der Bullets, ohne Einleitung, ohne Erklaerung."""
|
||||
|
||||
try:
|
||||
result, usage = await call_claude(prompt, tools=None, model=CLAUDE_MODEL_FAST)
|
||||
except Exception as e:
|
||||
logger.warning(f"Public-Mood Claude-Call fehlgeschlagen: {e}")
|
||||
return None, None
|
||||
|
||||
text = (result or "").strip()
|
||||
if not text or "zu duenn" in text.lower() or "too thin" in text.lower():
|
||||
logger.info("Public-Mood: Material zu duenn, kein Stimmungsbild generiert")
|
||||
return None, usage
|
||||
|
||||
# Sanity-Check: mindestens 1 Bullet (- am Zeilenanfang)
|
||||
if not any(line.lstrip().startswith("-") for line in text.split("\n")):
|
||||
logger.warning("Public-Mood: Claude-Antwort enthaelt keine Bullets, Sample: %r", text[:200])
|
||||
return None, usage
|
||||
|
||||
logger.info(
|
||||
"Public-Mood: %d Forum-Beitraege aus %d Quellen zu Stimmungsbild zusammengefasst",
|
||||
len(forum_articles), len(by_source),
|
||||
)
|
||||
return text, usage
|
||||
|
||||
@staticmethod
|
||||
def _parse_latest_developments(text: str, new_articles: list[dict] | None = None) -> list[str]:
|
||||
"""Extrahiert '- [DD.MM. HH:MM] ...'-Zeilen aus der Claude-Antwort.
|
||||
|
||||
@@ -1492,6 +1492,71 @@ class AgentOrchestrator:
|
||||
logger.warning("build_fact_context_block fehlgeschlagen: %s", ctx_err, exc_info=True)
|
||||
fact_context_block = ""
|
||||
|
||||
# Pipeline-Schritt 6b: Öffentliche Stimmung aus Foren-Quellen
|
||||
# (nur Artikel mit media_type='forum'). Eigene Kachel, kein Faktencheck.
|
||||
# Wird vor dem Lagebild-Schritt ausgefuehrt, damit das Lagebild bei
|
||||
# Bedarf darauf verweisen kann (z.B. Demo-Lagen mit Bezug zur Stimmung).
|
||||
try:
|
||||
# Bestand aller Foren-Artikel der Lage laden (inkl. media_type via JOIN)
|
||||
cursor_fm = await db.execute(
|
||||
"SELECT a.*, s.media_type AS media_type FROM articles a "
|
||||
"LEFT JOIN sources s ON s.name = a.source "
|
||||
"WHERE a.incident_id = ?",
|
||||
(incident_id,),
|
||||
)
|
||||
all_articles_with_mt = [dict(r) for r in await cursor_fm.fetchall()]
|
||||
forum_articles_in_db = [
|
||||
a for a in all_articles_with_mt
|
||||
if (a.get("media_type") or "").lower() == "forum"
|
||||
]
|
||||
# Aus dem aktuellen Refresh-Lauf zusaetzliche Foren-Artikel ergaenzen
|
||||
# (haben media_type aus feed_config, sind aber evtl. noch nicht in DB,
|
||||
# wenn die Persistierung anders laeuft — Robustheit).
|
||||
for art in new_articles_for_analysis:
|
||||
if (art.get("media_type") or "").lower() != "forum":
|
||||
continue
|
||||
# Duplikate vermeiden ueber source_url
|
||||
if any(a.get("source_url") == art.get("source_url") for a in forum_articles_in_db):
|
||||
continue
|
||||
forum_articles_in_db.append(art)
|
||||
|
||||
if forum_articles_in_db:
|
||||
await _pipe_start("public_mood")
|
||||
try:
|
||||
mood_agent = AnalyzerAgent()
|
||||
mood_text, mood_usage = await mood_agent.generate_public_mood(
|
||||
title, description, forum_articles_in_db,
|
||||
output_language=output_language,
|
||||
)
|
||||
if mood_usage:
|
||||
usage_acc.add(mood_usage)
|
||||
if mood_text:
|
||||
await db.execute(
|
||||
"UPDATE incidents SET public_mood = ?, public_mood_updated_at = ? WHERE id = ?",
|
||||
(mood_text, now, incident_id),
|
||||
)
|
||||
await db.commit()
|
||||
logger.info(
|
||||
"Public-Mood gespeichert fuer Incident %d (%d Foren-Artikel)",
|
||||
incident_id, len(forum_articles_in_db),
|
||||
)
|
||||
await _pipe_done(
|
||||
"public_mood",
|
||||
count_value=len(forum_articles_in_db),
|
||||
count_secondary=(1 if mood_text else 0),
|
||||
)
|
||||
except Exception as mood_err:
|
||||
logger.warning("Public-Mood fehlgeschlagen: %s", mood_err, exc_info=True)
|
||||
await _pipe_done("public_mood", count_value=0, count_secondary=0)
|
||||
else:
|
||||
await _pipe_skip("public_mood")
|
||||
except Exception as mood_outer_err:
|
||||
logger.warning("Public-Mood-Block uebersprungen: %s", mood_outer_err)
|
||||
try:
|
||||
await _pipe_skip("public_mood")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Pipeline-Schritt 7: Lagebild verfassen (jetzt mit Faktenkontext)
|
||||
await _pipe_start("summary")
|
||||
logger.info(
|
||||
|
||||
@@ -429,6 +429,16 @@ async def init_db():
|
||||
await db.commit()
|
||||
logger.info("Migration: latest_developments zu incidents hinzugefuegt")
|
||||
|
||||
if "public_mood" not in columns:
|
||||
await db.execute("ALTER TABLE incidents ADD COLUMN public_mood TEXT")
|
||||
await db.commit()
|
||||
logger.info("Migration: public_mood zu incidents hinzugefuegt")
|
||||
|
||||
if "public_mood_updated_at" not in columns:
|
||||
await db.execute("ALTER TABLE incidents ADD COLUMN public_mood_updated_at TIMESTAMP")
|
||||
await db.commit()
|
||||
logger.info("Migration: public_mood_updated_at zu incidents hinzugefuegt")
|
||||
|
||||
# Migration: Tabelle podcast_transcripts (URL-Cache fuer Transkripte)
|
||||
cursor = await db.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='podcast_transcripts'"
|
||||
|
||||
@@ -98,6 +98,8 @@ class IncidentResponse(BaseModel):
|
||||
visibility: str = "public"
|
||||
summary: Optional[str]
|
||||
latest_developments: Optional[str] = None
|
||||
public_mood: Optional[str] = None
|
||||
public_mood_updated_at: Optional[str] = None
|
||||
international_sources: bool = True
|
||||
include_telegram: bool = False
|
||||
created_by: int
|
||||
|
||||
@@ -32,6 +32,8 @@ _PIPELINE_STEPS_DE = [
|
||||
"tooltip": "Aus den Meldungen werden Ortsangaben erkannt und auf der Karte verortet."},
|
||||
{"key": "factcheck", "label": "Fakten prüfen", "icon": "shield",
|
||||
"tooltip": "Behauptungen aus den Meldungen werden gegeneinander abgeglichen: Bestätigt? Umstritten? Noch unklar?"},
|
||||
{"key": "public_mood", "label": "Stimmung erfassen", "icon": "message-circle",
|
||||
"tooltip": "Aus Foren-Quellen (z.B. 5ch, Hatena, Note) wird ein Stimmungsbild der öffentlichen Diskussion extrahiert. Keine Faktenlage, sondern dominante Themen und Bruchlinien."},
|
||||
{"key": "summary", "label": "Lagebild verfassen", "icon": "file-text",
|
||||
"tooltip": "Aus allen geprüften Meldungen wird ein zusammenhängendes Lagebild geschrieben, mit Quellenangaben am Text."},
|
||||
{"key": "qc", "label": "Qualitätscheck", "icon": "check-circle",
|
||||
@@ -53,6 +55,8 @@ _PIPELINE_STEPS_EN = [
|
||||
"tooltip": "Locations are extracted from the articles and placed on the map."},
|
||||
{"key": "factcheck", "label": "Checking facts", "icon": "shield",
|
||||
"tooltip": "Claims from the articles are cross-checked: Confirmed? Disputed? Still unclear?"},
|
||||
{"key": "public_mood", "label": "Reading the mood", "icon": "message-circle",
|
||||
"tooltip": "Forum sources (5ch, Hatena, Note, etc.) are summarised into a public-mood overview. Not factual, but dominant themes and fault lines."},
|
||||
{"key": "summary", "label": "Writing the briefing", "icon": "file-text",
|
||||
"tooltip": "All verified articles are combined into a coherent briefing with inline citations."},
|
||||
{"key": "qc", "label": "Quality check", "icon": "check-circle",
|
||||
|
||||
@@ -209,6 +209,7 @@
|
||||
<button class="tab-btn" data-tab="timeline" data-i18n="tab.timeline">Ereignis-Timeline</button>
|
||||
<button class="tab-btn" data-tab="karte" data-i18n="tab.map">Geografische Verteilung</button>
|
||||
<button class="tab-btn" data-tab="faktencheck" data-i18n="tab.factcheck">Faktencheck</button>
|
||||
<button class="tab-btn" data-tab="stimmung" data-i18n="tab.public_mood" id="tab-btn-stimmung" style="display:none;">Öffentliche Stimmung</button>
|
||||
<button class="tab-btn" data-tab="pipeline" data-i18n="tab.pipeline">Analysepipeline</button>
|
||||
<button class="tab-btn" data-tab="quellen" data-i18n="tab.sources_overview">Quellenübersicht</button>
|
||||
</div>
|
||||
@@ -293,6 +294,24 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tab-panel" id="panel-stimmung">
|
||||
<div class="card incident-analysis-stimmung" id="stimmung-card">
|
||||
<div class="card-header">
|
||||
<div class="card-title">
|
||||
<span data-i18n="card.public_mood">Öffentliche Stimmung</span>
|
||||
<span class="info-icon" data-tooltip="Themen und Bruchlinien aus Foren-Quellen (z.B. 5ch, Hatena, Note). KEINE Faktenlage - reines Stimmungsmaterial. Beitraege sind anonym und koennen Trolling enthalten."><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></svg></span>
|
||||
</div>
|
||||
<span class="stimmung-timestamp" id="stimmung-timestamp"></span>
|
||||
</div>
|
||||
<div id="stimmung-content">
|
||||
<div id="stimmung-text" class="summary-text" style="padding:8px 16px;"></div>
|
||||
<div style="padding:0 16px 16px; font-size:11px; color:var(--text-disabled); border-top:1px solid var(--border); margin-top:8px; padding-top:8px;">
|
||||
Hinweis: Forenbeiträge sind anonyme Online-Stimmungen, keine Faktenlage. Sie fließen nicht in den Faktencheck ein.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tab-panel" id="panel-pipeline">
|
||||
<div class="card pipeline-card" id="pipeline-card">
|
||||
<div class="card-header">
|
||||
@@ -778,10 +797,10 @@
|
||||
<script src="/static/js/i18n.js?v=20260513a"></script>
|
||||
<script src="/static/js/api.js?v=20260423a"></script>
|
||||
<script src="/static/js/ws.js?v=20260316b"></script>
|
||||
<script src="/static/js/components.js?v=20260514e"></script>
|
||||
<script src="/static/js/components.js?v=20260522a"></script>
|
||||
<script src="/static/js/layout.js?v=20260513f"></script>
|
||||
<script src="/static/js/pipeline.js?v=20260513d"></script>
|
||||
<script src="/static/js/app.js?v=20260514e"></script>
|
||||
<script src="/static/js/app.js?v=20260522a"></script>
|
||||
<script src="/static/js/cluster-data.js?v=20260322f"></script>
|
||||
<script src="/static/js/tutorial.js?v=20260316z"></script>
|
||||
<script src="/static/js/chat.js?v=20260514e"></script>
|
||||
|
||||
@@ -1131,6 +1131,26 @@ const App = {
|
||||
: '';
|
||||
}
|
||||
|
||||
// Öffentliche Stimmung (Foren-Kachel): Tab + Inhalt nur einblenden,
|
||||
// wenn fuer diese Lage tatsaechlich Stimmungs-Text vorhanden ist.
|
||||
const stimmungTabBtn = document.getElementById('tab-btn-stimmung');
|
||||
const stimmungText = document.getElementById('stimmung-text');
|
||||
const stimmungTs = document.getElementById('stimmung-timestamp');
|
||||
const moodText = (incident.public_mood || '').trim();
|
||||
if (moodText && stimmungTabBtn) {
|
||||
stimmungTabBtn.style.display = '';
|
||||
if (stimmungText) stimmungText.innerHTML = UI.renderPublicMood(moodText);
|
||||
if (stimmungTs && incident.public_mood_updated_at) {
|
||||
const mUpd = parseUTC(incident.public_mood_updated_at);
|
||||
if (mUpd) {
|
||||
stimmungTs.textContent = `Stand: ${mUpd.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric', timeZone: TIMEZONE })} ${mUpd.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit', timeZone: TIMEZONE })} Uhr`;
|
||||
}
|
||||
}
|
||||
} else if (stimmungTabBtn) {
|
||||
stimmungTabBtn.style.display = 'none';
|
||||
if (stimmungText) stimmungText.innerHTML = '';
|
||||
}
|
||||
|
||||
{ const _e = document.getElementById('meta-refresh-mode'); if (_e) {
|
||||
if (incident.refresh_mode === 'auto' && incident.refresh_start_time) {
|
||||
const intervalText = App._formatInterval(incident.refresh_interval);
|
||||
|
||||
@@ -813,6 +813,26 @@ const UI = {
|
||||
return html;
|
||||
},
|
||||
|
||||
/**
|
||||
* Rendert die "Öffentliche Stimmung"-Kachel.
|
||||
* Eingabe ist Markdown mit "- "-Bullets (vom AnalyzerAgent.generate_public_mood).
|
||||
* Quellen-Pills brauchen wir hier nicht — die Bullet-Texte nennen die Foren-Herkunft
|
||||
* explizit ("auf 5ch /seiji/ ...", "Hatena-Kommentare betonen ...").
|
||||
*/
|
||||
renderPublicMood(text) {
|
||||
if (!text) return '<span style="color:var(--text-disabled);">Noch kein Stimmungsbild erfasst.</span>';
|
||||
const bulletLines = text.split("\n").map(l => l.trim()).filter(l => l.startsWith("- "));
|
||||
if (bulletLines.length === 0) {
|
||||
// Fliesstext-Fallback: HTML-escapen + Zeilenumbrueche
|
||||
return this.escape(text).replace(/\n/g, '<br>');
|
||||
}
|
||||
const items = bulletLines.map(l => {
|
||||
const body = l.replace(/^-\s+/, '');
|
||||
return `<li>${this.escape(body)}</li>`;
|
||||
}).join('');
|
||||
return `<ul style="margin:4px 0 4px 18px;line-height:1.7;">${items}</ul>`;
|
||||
},
|
||||
|
||||
/**
|
||||
* Rendert "Neueste Entwicklungen" für Live-Monitoring (adhoc).
|
||||
* Erwartet Bullets im Format "- [DD.MM. HH:MM] Text {Quelle1, Quelle2}".
|
||||
|
||||
In neuem Issue referenzieren
Einen Benutzer sperren