Commits vergleichen

4 Commits

Autor SHA1 Nachricht Datum
7d9bca12ee Merge pull request 'Bericht-Export: Neueste Entwicklungen sauber formatieren' (#49) from export-fix-live into main 2026-06-22 08:51:30 +02:00
claude-dev
3e64539aa3 Bericht-Export: "Neueste Entwicklungen" sauber formatieren
Live-Monitoring-Lagen (adhoc) zeigten im Export die Entwicklungen als
Bullet-Liste mit "[DD.MM. HH:MM] Text" und Quellen-Links. Jetzt:

- Abschnitt heisst "Neueste Entwicklungen" statt "Zusammenfassung"
  (Ueberschrift, Inhaltsverzeichnis). Title folgt dem Inhalt.
- Pro Eintrag eigene Datum/Uhrzeit-Zeile (TT.MM.JJ, HH:MM Uhr),
  darunter als eigener Absatz der Meldungstext.
- Keine Links: Quellen-Klammern {Name|URL} und [N]-Zitate werden entfernt.
- Gilt fuer PDF und DOCX. Research-Lagen und KI-Executive-Summary
  unveraendert.

Nebenbei: DOCX-Export crashte bei Beschreibungen ueber 255 Zeichen
(python-docx Core-Property "subject" Limit). Subject wird jetzt gekappt.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 06:50:25 +00:00
Claude Code
1647a6f50a Refresh-Intervall: Mindestzeiten je nach Quellen erzwingen
Mindest-Aktualisierungsintervall im Lage-Modal: 30 Minuten Basis, 45 bei X oder Telegram, 60 bei X und Telegram zugleich (internationale Quellen ohne Einfluss). Minutenwerte darunter sind im UI nicht mehr einstellbar (min-Attribut, Clamp am Feld und beim Speichern). Beim Umstellen von Stunden auf Minuten wird das Minimum gesetzt und als Hinweis angezeigt. Gilt für Anlegen und Bearbeiten.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 20:17:32 +00:00
Claude Code
c53e260c6c UI: Art der Lage im Lage-Modal nach ganz oben verschoben
Die Typ-Auswahl (Live-Monitoring/Recherche) steht jetzt als erstes Feld vor Titel und Beschreibung, beim Anlegen und beim Bearbeiten (gemeinsames Modal modal-new). Auf ausdrücklichen Wunsch direkt auf main aufgespielt, Staging-Zyklus umgangen.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 19:52:49 +00:00
5 geänderte Dateien mit 168 neuen und 38 gelöschten Zeilen

Datei anzeigen

@@ -7,6 +7,7 @@ import re
import uuid
from collections import defaultdict
from datetime import datetime
from html import escape as _html_escape
from pathlib import Path
import pikepdf
@@ -153,6 +154,66 @@ def _markdown_to_html(text: str) -> str:
return '\n'.join(result)
def _parse_developments_for_export(text: str) -> list[tuple[str, str]]:
"""Parst die 'Neuesten Entwicklungen' (latest_developments) fuer den Export.
Eingabeformat je Eintrag: '- [DD.MM. HH:MM] Text {Quelle|URL, ...}'.
Liefert (datum_label, body) je Eintrag in gespeicherter Reihenfolge.
Quellen-Klammern und [N]-Zitate werden entfernt — der Export zeigt bewusst
KEINE Links. Das gespeicherte Format enthaelt kein Jahr; fehlt es, wird das
aktuelle Jahr ergaenzt (Live-Monitoring-Berichte sind tagesaktuell).
"""
if not text:
return []
year2 = datetime.now(TIMEZONE).strftime("%y")
bullet_re = re.compile(
r"^\s*(?:[-*•]\s*)?\[\s*(\d{1,2})\.(\d{1,2})\.?(?:(\d{2,4}))?\s+(\d{1,2}:\d{2})\s*\]\s*(.+?)\s*$"
)
trailing_braces = re.compile(r"\s*\{[^{}]*\}\s*\.?\s*$")
citation_re = re.compile(r"\s*\[\d{1,5}[a-z]?\]")
result: list[tuple[str, str]] = []
for raw in text.splitlines():
line = raw.strip()
if not line:
continue
m = bullet_re.match(line)
if not m:
continue
day, month, year, time = m.group(1), m.group(2), m.group(3), m.group(4)
body = m.group(5).strip()
# Quellen-Klammer am Ende und Inline-[N]-Zitate entfernen (keine Links)
body = trailing_braces.sub("", body).strip()
body = citation_re.sub("", body).strip()
if not body:
continue
yy = year[-2:] if year else year2
label = f"{int(day):02d}.{int(month):02d}.{yy}, {time} Uhr"
result.append((label, body))
return result
def _format_latest_developments_html(text: str) -> str:
"""Rendert die 'Neuesten Entwicklungen' als HTML-Block fuer den PDF-Export.
Pro Eintrag: Datum/Uhrzeit-Zeile, darunter (eigener Absatz) der Meldungstext.
Keine Quellen-Links. Faellt bei nicht-parsebarem Text auf _markdown_to_html zurueck.
"""
pairs = _parse_developments_for_export(text)
if not pairs:
return _markdown_to_html(text)
blocks = []
for label, body in pairs:
body_html = _html_escape(body)
body_html = re.sub(r'\*\*(.+?)\*\*', r'<strong>\1</strong>', body_html)
blocks.append(
'<div class="dev-entry">'
f'<div class="dev-entry-date">{_html_escape(label)}</div>'
f'<div class="dev-entry-body">{body_html}</div>'
'</div>'
)
return "\n".join(blocks)
def _truncate_lagebild(summary_text: str, max_chars: int = 4000) -> str:
"""Lagebild für den Lagebericht auf die Zusammenfassung kürzen.
@@ -479,6 +540,10 @@ def _build_export_metadata(
subject = (incident.get("description") or "").strip()
if not subject:
subject = f"{type_label} zu: {title_raw}"
# DOCX-Core-Property "subject" erzwingt ein 255-Zeichen-Limit; laengere
# Beschreibungen wuerden den Word-Export sonst mit ValueError abbrechen.
if len(subject) > 255:
subject = subject[:255]
# Keywords sammeln (Reihenfolge relevant für Anzeige, Dedup mit dict.fromkeys)
keywords: list[str] = ["OSINT", type_label]
@@ -711,22 +776,33 @@ async def generate_pdf(
else: # full
sections = {"zusammenfassung", "bericht", "faktencheck", "quellen", "timeline"}
# Fuer Research-Lagen: Zusammenfassung aus dem Bericht extrahieren
# Zusammenfassungs-Quelle bestimmen:
# - Research: ZUSAMMENFASSUNG/UEBERBLICK aus dem Bericht extrahieren.
# - Live-Monitoring (adhoc): "Neueste Entwicklungen" aus latest_developments,
# ohne Quellen-Links, Datum/Uhrzeit als eigene Zeile.
# - sonst: KI-Executive-Summary (executive_summary_html).
is_research = incident.get("type") == "research"
all_sources = _prepare_sources(incident)
latest_dev = (incident.get("latest_developments") or "").strip()
zusammenfassung_html = executive_summary_html
bericht_summary = incident.get("summary", "")
zusammenfassung_title = "Zusammenfassung"
summary_has_links = True
if is_research and bericht_summary:
extracted_html, remaining = _extract_zusammenfassung(bericht_summary, all_sources)
if extracted_html:
zusammenfassung_html = extracted_html
zusammenfassung_title = "Zusammenfassung"
bericht_summary = remaining
elif not is_research and latest_dev:
dev_html = _format_latest_developments_html(latest_dev)
if dev_html:
zusammenfassung_html = dev_html
zusammenfassung_title = "Neueste Entwicklungen"
summary_has_links = False # Quellen bewusst entfernt
# Auch das (nicht-research) Executive Summary linkifizieren — ggf. enthaelt es Zitate
if not is_research and zusammenfassung_html:
# KI-/Research-Zusammenfassung linkifizieren; Developments bleiben linkfrei
if not is_research and summary_has_links and zusammenfassung_html:
zusammenfassung_html = _linkify_citations_html(zusammenfassung_html, all_sources)
meta = _build_export_metadata(
@@ -799,20 +875,27 @@ async def generate_docx(
else: # full
sections = {"zusammenfassung", "bericht", "faktencheck", "quellen", "timeline"}
# Fuer Research-Lagen: Zusammenfassung aus dem Bericht extrahieren
# Zusammenfassungs-Quelle bestimmen (analog generate_pdf):
# Research -> Bericht-Extrakt, Live-Monitoring -> "Neueste Entwicklungen", sonst KI.
is_research = incident.get("type") == "research"
all_sources = _prepare_sources(incident)
latest_dev = (incident.get("latest_developments") or "").strip()
zusammenfassung_text = executive_summary_text
bericht_summary = incident.get("summary") or "Keine Zusammenfassung verfügbar."
zusammenfassung_title = "Zusammenfassung"
zusammenfassung_lines: list[str] = []
zusammenfassung_developments: list[tuple[str, str]] = []
if is_research and bericht_summary:
extracted_lines, remaining = _extract_zusammenfassung_lines(bericht_summary)
if extracted_lines:
zusammenfassung_lines = extracted_lines
zusammenfassung_title = "Zusammenfassung"
bericht_summary = remaining
elif not is_research and latest_dev:
dev_pairs = _parse_developments_for_export(latest_dev)
if dev_pairs:
zusammenfassung_developments = dev_pairs
zusammenfassung_title = "Neueste Entwicklungen"
meta = _build_export_metadata(
incident, articles, fact_checks, all_sources, creator, scope, sections,
@@ -890,11 +973,23 @@ async def generate_docx(
doc.add_page_break()
# --- Zusammenfassung / Executive Summary ---
# --- Zusammenfassung / Neueste Entwicklungen ---
if "zusammenfassung" in sections:
doc.add_heading(zusammenfassung_title, level=1)
if zusammenfassung_lines:
if zusammenfassung_developments:
# Live-Monitoring: pro Eintrag Datum/Uhrzeit-Zeile + Absatz mit Text, ohne Links
for label, body in zusammenfassung_developments:
date_para = doc.add_paragraph()
date_para.paragraph_format.space_after = Pt(1)
run = date_para.add_run(label)
run.bold = True
run.font.size = Pt(9)
run.font.color.rgb = RGBColor(0x0a, 0x18, 0x32)
body_para = doc.add_paragraph()
body_para.paragraph_format.space_after = Pt(8)
body_para.add_run(re.sub(r'\*\*(.+?)\*\*', r'\1', body))
elif zusammenfassung_lines:
for line in zusammenfassung_lines:
_add_docx_paragraph_with_citations(doc, line, all_sources, style='List Bullet')
else:

Datei anzeigen

@@ -47,6 +47,12 @@ body { font-family: -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-se
.exec-summary ul { margin: 8px 0 0 18px; }
.exec-summary li { margin-bottom: 6px; line-height: 1.6; }
/* Neueste Entwicklungen (Live-Monitoring) */
.dev-entry { margin-bottom: 12px; }
.dev-entry:last-child { margin-bottom: 0; }
.dev-entry-date { font-size: 9pt; font-weight: 600; color: #0a1832; margin-bottom: 2px; }
.dev-entry-body { font-size: 10.5pt; line-height: 1.5; }
/* Lagebild */
.lagebild-content { line-height: 1.7; }
.lagebild-content p { margin-bottom: 8px; }
@@ -99,7 +105,7 @@ tr:nth-child(even) { background: #f8f9fa; }
<div class="toc">
<h2>Inhaltsverzeichnis</h2>
<ul class="toc-list">
{% if 'zusammenfassung' in sections %}<li><a href="#sec-zusammenfassung">Zusammenfassung</a></li>{% endif %}
{% if 'zusammenfassung' in sections %}<li><a href="#sec-zusammenfassung">{{ zusammenfassung_title }}</a></li>{% endif %}
{% if 'bericht' in sections %}<li><a href="#sec-bericht">{% if incident.type == "research" %}Recherchebericht{% else %}Lagebild{% endif %}</a></li>{% endif %}
{% if 'faktencheck' in sections and fact_checks %}<li><a href="#sec-faktencheck">Faktencheck</a></li>{% endif %}
{% if 'quellen' in sections and sources %}<li><a href="#sec-quellen">Quellenverzeichnis</a></li>{% endif %}

Datei anzeigen

@@ -1232,18 +1232,14 @@ async def export_incident(
snapshots = [dict(r) for r in await cursor.fetchall()]
# Zusammenfassung fuer den Export:
# - Bei Adhoc-Lagen primaer "Neueste Entwicklungen" (latest_developments) als Markdown-Bullets,
# weil Live-Monitoring von Aktualitaet lebt.
# - Fallback (oder bei Research): Executive Summary (KI-generiert, gecacht).
# - Live-Monitoring (adhoc) zeigt primaer "Neueste Entwicklungen" (latest_developments).
# Das Rendering (Datum/Uhrzeit als eigene Zeile, ohne Links) uebernimmt der
# Report-Generator direkt aus incident["latest_developments"].
# - Executive Summary (KI, gecacht) dient nur als Fallback (oder bei Research-Lagen).
is_adhoc = (incident.get("type") or "adhoc") != "research"
latest_dev = (incident.get("latest_developments") or "").strip()
exec_summary = None
if is_adhoc and latest_dev:
from report_generator import _markdown_to_html as _md_to_html
exec_summary = _md_to_html(latest_dev)
if not exec_summary:
exec_summary = incident.get("executive_summary")
if not exec_summary:
exec_summary = incident.get("executive_summary")
if not exec_summary and not (is_adhoc and latest_dev):
summary_text = incident.get("summary") or ""
exec_summary = await generate_executive_summary(summary_text)
await db.execute(
@@ -1251,6 +1247,7 @@ async def export_incident(
(exec_summary, incident_id),
)
await db.commit()
exec_summary = exec_summary or ""
date_str = datetime.now(TIMEZONE).strftime("%Y%m%d")
slug = _slugify(incident["title"])

Datei anzeigen

@@ -352,6 +352,16 @@
</div>
<form id="new-incident-form">
<div class="modal-body">
<div class="form-group">
<label for="inc-type" data-i18n="modal.field.type">Art der Lage</label>
<select id="inc-type" onchange="toggleTypeDefaults()">
<option value="adhoc" data-i18n="modal.option.type_adhoc">Live-Monitoring : Ereignis beobachten</option>
<option value="research" data-i18n="modal.option.type_research">Recherche : Thema analysieren</option>
</select>
<div class="form-hint" id="type-hint" data-i18n="modal.hint.type_adhoc">
Durchsucht laufend hunderte Nachrichtenquellen nach neuen Meldungen. Empfohlen: Automatische Aktualisierung.
</div>
</div>
<div class="form-group">
<label for="inc-title" data-i18n="modal.new_incident.title_field">Titel des Vorfalls</label>
<input type="text" id="inc-title" required aria-required="true" placeholder="z.B. Explosion in Madrid" data-i18n-attr="placeholder:modal.placeholder.title">
@@ -367,16 +377,6 @@
<textarea id="inc-description" placeholder="Weitere Details zum Vorfall (optional)" data-i18n-attr="placeholder:modal.placeholder.description"></textarea>
</div>
<div class="form-group">
<label for="inc-type" data-i18n="modal.field.type">Art der Lage</label>
<select id="inc-type" onchange="toggleTypeDefaults()">
<option value="adhoc" data-i18n="modal.option.type_adhoc">Live-Monitoring : Ereignis beobachten</option>
<option value="research" data-i18n="modal.option.type_research">Recherche : Thema analysieren</option>
</select>
<div class="form-hint" id="type-hint" data-i18n="modal.hint.type_adhoc">
Durchsucht laufend hunderte Nachrichtenquellen nach neuen Meldungen. Empfohlen: Automatische Aktualisierung.
</div>
</div>
<div class="form-group">
<label data-i18n="modal.field.sources">Quellen</label>
<div class="toggle-group">
@@ -420,7 +420,7 @@
<div class="form-group conditional-field" id="refresh-interval-field">
<label for="inc-refresh-value" data-i18n="modal.field.interval">Intervall</label>
<div class="interval-input-group">
<input type="number" id="inc-refresh-value" min="10" value="15">
<input type="number" id="inc-refresh-value" min="30" value="30">
<select id="inc-refresh-unit" onchange="updateIntervalMin()">
<option value="1" selected data-i18n="modal.unit.minutes">Minuten</option>
<option value="60" data-i18n="modal.unit.hours">Stunden</option>
@@ -428,6 +428,7 @@
<option value="10080" data-i18n="modal.unit.weeks">Wochen</option>
</select>
</div>
<div class="form-hint" id="interval-min-hint" style="display:none;"></div>
</div>
<div class="form-group conditional-field" id="refresh-starttime-field">
<label for="inc-refresh-starttime"><span data-i18n="modal.field.start_time">Erste Aktualisierung um</span> <span class="info-icon tooltip-below" data-tooltip="Legt den Startzeitpunkt fest. Danach wird im eingestellten Intervall aktualisiert."><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></label>

Datei anzeigen

@@ -578,8 +578,16 @@ const App = {
// Telegram-Kategorien Toggle
const tgCheckbox = document.getElementById('inc-telegram');
if (tgCheckbox) {
tgCheckbox.addEventListener('change', () => updateIntervalMin());
}
{ const xCheckbox = document.getElementById('inc-x');
if (xCheckbox) xCheckbox.addEventListener('change', () => updateIntervalMin()); }
{ const ivInput = document.getElementById('inc-refresh-value');
if (ivInput) ivInput.addEventListener('change', () => {
const u = parseInt(document.getElementById('inc-refresh-unit').value);
const m = (u === 1) ? _getMinIntervalMinutes() : 1;
if (isNaN(parseInt(ivInput.value)) || parseInt(ivInput.value) < m) ivInput.value = m;
}); }
// Feedback
@@ -1836,9 +1844,9 @@ const App = {
// === Event Handlers ===
_getFormData() {
const value = parseInt(document.getElementById('inc-refresh-value').value) || 15;
const value = parseInt(document.getElementById('inc-refresh-value').value) || 30;
const unit = parseInt(document.getElementById('inc-refresh-unit').value) || 1;
const interval = Math.max(10, Math.min(10080, value * unit));
const interval = Math.max(_getMinIntervalMinutes(), Math.min(10080, value * unit));
return {
title: document.getElementById('inc-title').value.trim(),
description: document.getElementById('inc-description').value.trim() || null,
@@ -2294,6 +2302,7 @@ async handleRefresh() {
updateSourcesHint();
toggleTypeDefaults(true);
toggleRefreshInterval();
updateIntervalMin();
// Modal-Titel und Submit ändern
{ const _e = document.getElementById('modal-new-title'); if (_e) _e.textContent = (typeof T === 'function') ? T('modal.new_incident.edit_title', 'Lage bearbeiten') : 'Lage bearbeiten'; }
@@ -3633,6 +3642,7 @@ function openModal(id) {
document.getElementById('inc-notify-status-change').checked = false;
toggleTypeDefaults();
toggleRefreshInterval();
updateIntervalMin();
}
const modal = document.getElementById(id);
modal._previousFocus = document.activeElement;
@@ -3814,17 +3824,38 @@ function toggleRefreshInterval() {
if (startField) startField.classList.toggle('visible', mode === 'auto');
}
function _getMinIntervalMinutes() {
// Mindest-Intervall (Minuten) je nach Quellen: 30 Basis, 45 bei X oder Telegram, 60 bei beiden. International zaehlt nicht.
const tg = document.getElementById('inc-telegram');
const x = document.getElementById('inc-x');
const tgOn = !!(tg && tg.checked);
const xOn = !!(x && x.checked);
if (tgOn && xOn) return 60;
if (tgOn || xOn) return 45;
return 30;
}
function updateIntervalMin() {
const unit = parseInt(document.getElementById('inc-refresh-unit').value);
const input = document.getElementById('inc-refresh-value');
const minMinutes = _getMinIntervalMinutes();
const hint = document.getElementById('interval-min-hint');
if (unit === 1) {
// Minuten: Minimum 10
input.min = 10;
if (parseInt(input.value) < 10) input.value = 10;
// Minuten: dynamisches Minimum (30 / 45 bei X oder Telegram / 60 bei beiden)
input.min = minMinutes;
if (isNaN(parseInt(input.value)) || parseInt(input.value) < minMinutes) input.value = minMinutes;
if (hint) {
let zusatz = '';
if (minMinutes === 45) zusatz = ' (X oder Telegram aktiv)';
else if (minMinutes === 60) zusatz = ' (X und Telegram aktiv)';
hint.textContent = 'Mindestens ' + minMinutes + ' Minuten' + zusatz;
hint.style.display = '';
}
} else {
// Stunden/Tage/Wochen: Minimum 1
// Stunden/Tage/Wochen: eine Einheit liegt ueber jedem Minuten-Minimum
input.min = 1;
if (parseInt(input.value) < 1) input.value = 1;
if (isNaN(parseInt(input.value)) || parseInt(input.value) < 1) input.value = 1;
if (hint) hint.style.display = 'none';
}
}