11 Commits

Autor SHA1 Nachricht Datum
Claude Code
3a3076c4cf Orchestrator: eigener Worker pro Organisation (parallele Lanes)
Ersetzt die globale Warteschlange mit einem einzelnen Worker durch
Per-Tenant-Lanes: jede Organisation bekommt eine eigene Queue und einen
eigenen Worker-Task, die unabhaengig und parallel laufen. Lanes werden
lazy angelegt und bei Leerlauf (60s) wieder beendet.

- Skalarer Zustand (_current_task, _cancel_event) -> Maps pro incident_id
  (_current_tasks, _cancel_events); _queued_ids/_cancel_requested bleiben
  global (IDs sind lane-uebergreifend eindeutig).
- enqueue_refresh/cancel_refresh: Signatur unveraendert, routen intern per
  tenant_id in die passende Lane. Oeffentliche Lagen (tenant_id NULL) in
  fester PUBLIC_LANE.
- Optionales Ventil ORCHESTRATOR_MAX_PARALLEL (Default 0 = unbegrenzt)
  begrenzt bei Bedarf gleichzeitige Recherchen ueber alle Lanes.
- /refreshing-Endpoint auf _current_tasks-Map umgestellt, Response-Format
  unveraendert (pro Tenant laeuft weiterhin hoechstens eine Lage).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 20:12:03 +00:00
e0107a1bb1 Merge pull request 'Hotfix: E-Mail-Versand mit Retry (IONOS-Farm-Stoerung)' (#50) from fix/email-retry-live into main 2026-07-10 12:20:21 +02:00
claude-dev
d32746b00f fix(email): SMTP-Versand mit 3 Versuchen und 20s-Timeout
Die IONOS-Farm (smtp.ionos.de, 10 Backends im DNS-Round-Robin) hatte am
10.07. teils tote/lahme Backends - ein einzelner Sendeversuch schlug dann
fehl (Timed out waiting for server ready message). Jeder Retry oeffnet
eine neue Verbindung und trifft per Round-Robin ein anderes Backend.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 10:17:59 +00:00
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
c3a0ee4538 Promote develop → main (2026-06-02 17:14 UTC) 2026-06-02 19:14:06 +02:00
aa36a9a38f Promote develop → main (2026-06-02 16:10 UTC) 2026-06-02 18:10:43 +02:00
b02578e48b Promote develop → main (2026-05-25 23:14 UTC) 2026-05-26 01:14:22 +02:00
38ce26f0be Promote develop → main (2026-05-22 19:10 UTC) 2026-05-22 21:10:42 +02:00
7 geänderte Dateien mit 325 neuen und 117 gelöschten Zeilen

Datei anzeigen

@@ -2,6 +2,7 @@
import asyncio import asyncio
import json import json
import logging import logging
import os
import re import re
from datetime import datetime from datetime import datetime
from config import TIMEZONE from config import TIMEZONE
@@ -401,48 +402,83 @@ async def _send_email_notifications_for_incident(
class AgentOrchestrator: class AgentOrchestrator:
"""Verwaltet die Claude-Agenten-Queue und koordiniert Recherche-Zyklen.""" """Koordiniert die Recherche-Zyklen: pro Organisation eine unabhaengige,
parallel laufende Worker-Lane."""
# Lane-Schluessel fuer oeffentliche/systemweite Lagen (tenant_id IS NULL).
PUBLIC_LANE = 0
# Sekunden ohne Auftrag, nach denen eine Lane sich beendet und aufraeumt.
IDLE_TIMEOUT = 60
def __init__(self): def __init__(self):
self._queue: asyncio.Queue = asyncio.Queue()
self._running = False self._running = False
self._current_task: Optional[int] = None
# Session-Start des aktuellen Tasks (UTC ISO mit 'Z'). Ueberspannt Multi-Pass
# und Retries innerhalb derselben Queue-Abarbeitung — verhindert, dass der
# Frontend-Timer beim Seiten-Reload auf den Pass/Retry-Start zurueckspringt.
self._current_task_started_at: Optional[str] = None
self._ws_manager = None self._ws_manager = None
self._queued_ids: set[int] = set() # Pro Organisation (tenant_id) eine eigene Queue + ein eigener Worker-Task.
# So arbeiten Organisationen unabhaengig und parallel, statt sich eine
# globale Warteschlange zu teilen. Lanes werden bei Bedarf angelegt und bei
# Leerlauf wieder beendet. Der Lock schuetzt das Anlegen/Beenden gegen
# gleichzeitiges Einreihen.
self._lanes: dict[int, asyncio.Queue] = {}
self._lane_workers: dict[int, asyncio.Task] = {}
self._lanes_lock = asyncio.Lock()
# Globale Zustaende — incident-IDs sind lane-uebergreifend eindeutig.
self._queued_ids: set[int] = set() # eingereiht (in irgendeiner Lane)
# incident_id -> Session-Start (UTC ISO mit 'Z'). Ersetzt den frueheren
# Einzelwert; haelt den Frontend-Timer ueber Multi-Pass/Retry stabil.
self._current_tasks: dict[int, str] = {}
self._cancel_requested: set[int] = set() self._cancel_requested: set[int] = set()
self._cancel_event: asyncio.Event | None = None # incident_id -> Cancel-Event des laufenden Tasks. cancel_refresh laeuft in
# einem anderen async-Kontext als der Worker und kann die ContextVar nicht
# nutzen, daher diese direkte Zuordnung.
self._cancel_events: dict[int, asyncio.Event] = {}
# Optionales Sicherheitsventil gegen Ueberlastung des gemeinsamen Claude-
# Kontos: begrenzt die Zahl GLEICHZEITIG laufender Recherchen ueber alle
# Lanes. Default 0 = unbegrenzt (jede Organisation voellig unabhaengig).
_max = int(os.getenv("ORCHESTRATOR_MAX_PARALLEL", "0") or "0")
self._global_sem: Optional[asyncio.Semaphore] = asyncio.Semaphore(_max) if _max > 0 else None
def set_ws_manager(self, ws_manager): def set_ws_manager(self, ws_manager):
"""WebSocket-Manager setzen für Echtzeit-Updates.""" """WebSocket-Manager setzen für Echtzeit-Updates."""
self._ws_manager = ws_manager self._ws_manager = ws_manager
async def start(self): async def start(self):
"""Queue-Worker starten.""" """Orchestrator aktivieren. Worker-Lanes werden pro Organisation bei der
ersten Anfrage angelegt (lazy)."""
self._running = True self._running = True
asyncio.create_task(self._worker())
logger.info("Agenten-Orchestrator gestartet") logger.info("Agenten-Orchestrator gestartet")
async def stop(self): async def stop(self):
"""Queue-Worker stoppen.""" """Orchestrator stoppen und alle Lane-Worker beenden."""
self._running = False self._running = False
async with self._lanes_lock:
workers = list(self._lane_workers.values())
self._lane_workers.clear()
self._lanes.clear()
for task in workers:
task.cancel()
logger.info("Agenten-Orchestrator gestoppt") logger.info("Agenten-Orchestrator gestoppt")
async def enqueue_refresh(self, incident_id: int, trigger_type: str = "manual", user_id: int = None) -> bool: async def enqueue_refresh(self, incident_id: int, trigger_type: str = "manual", user_id: int = None) -> bool:
"""Refresh-Auftrag in die Queue stellen. Gibt False zurueck wenn bereits in Queue/aktiv.""" """Refresh-Auftrag in die Lane der Organisation stellen. Gibt False zurueck
if incident_id in self._queued_ids or self._current_task == incident_id: wenn die Lage dort bereits wartet oder gerade laeuft."""
if incident_id in self._queued_ids or incident_id in self._current_tasks:
logger.info(f"Refresh fuer Lage {incident_id} uebersprungen: bereits aktiv/in Queue") logger.info(f"Refresh fuer Lage {incident_id} uebersprungen: bereits aktiv/in Queue")
return False return False
visibility, created_by, tenant_id = await self._get_incident_visibility(incident_id) visibility, created_by, tenant_id = await self._get_incident_visibility(incident_id)
lane_key = tenant_id if tenant_id else self.PUBLIC_LANE
self._queued_ids.add(incident_id) async with self._lanes_lock:
await self._queue.put((incident_id, trigger_type, user_id)) queue = self._lanes.get(lane_key)
queue_size = self._queue.qsize() if queue is None:
logger.info(f"Refresh fuer Lage {incident_id} eingereiht (Queue: {queue_size}, Trigger: {trigger_type})") queue = asyncio.Queue()
self._lanes[lane_key] = queue
self._lane_workers[lane_key] = asyncio.create_task(self._worker(lane_key, queue))
logger.info(f"Neue Worker-Lane fuer Organisation {lane_key} gestartet")
self._queued_ids.add(incident_id)
queue.put_nowait((incident_id, trigger_type, user_id))
queue_size = queue.qsize()
logger.info(f"Refresh fuer Lage {incident_id} eingereiht (Lane {lane_key}, Queue: {queue_size}, Trigger: {trigger_type})")
if self._ws_manager: if self._ws_manager:
await self._ws_manager.broadcast_for_incident({ await self._ws_manager.broadcast_for_incident({
@@ -455,11 +491,12 @@ class AgentOrchestrator:
async def cancel_refresh(self, incident_id: int) -> bool: async def cancel_refresh(self, incident_id: int) -> bool:
"""Fordert Abbruch eines laufenden oder wartenden Refreshes an.""" """Fordert Abbruch eines laufenden oder wartenden Refreshes an."""
# Check if it's the currently running task # Laeuft die Lage gerade?
if self._current_task == incident_id: if incident_id in self._current_tasks:
self._cancel_requested.add(incident_id) self._cancel_requested.add(incident_id)
if self._cancel_event: ev = self._cancel_events.get(incident_id)
self._cancel_event.set() if ev:
ev.set()
logger.info(f"Cancel angefordert fuer laufende Lage {incident_id}") logger.info(f"Cancel angefordert fuer laufende Lage {incident_id}")
if self._ws_manager: if self._ws_manager:
try: try:
@@ -473,25 +510,33 @@ class AgentOrchestrator:
}, vis, cb, tid) }, vis, cb, tid)
return True return True
# Check if it's in the queue (not yet started) # Wartet die Lage noch in ihrer Lane?
if incident_id in self._queued_ids: if incident_id in self._queued_ids:
self._queued_ids.discard(incident_id) self._queued_ids.discard(incident_id)
# Remove from asyncio queue (rebuild without this ID) # Betroffene Lane bestimmen und die ID aus deren Queue entfernen.
try:
_vis, _cb, _tid = await self._get_incident_visibility(incident_id)
except Exception:
_tid = None
lane_key = _tid if _tid else self.PUBLIC_LANE
removed = False removed = False
new_items = [] async with self._lanes_lock:
while not self._queue.empty(): queue = self._lanes.get(lane_key)
try: if queue is not None:
item = self._queue.get_nowait() new_items = []
iid = item[0] if isinstance(item, tuple) else item while not queue.empty():
if iid == incident_id: try:
removed = True item = queue.get_nowait()
self._queue.task_done() except Exception:
else: break
new_items.append(item) iid = item[0] if isinstance(item, tuple) else item
except Exception: if iid == incident_id:
break removed = True
for item in new_items: queue.task_done()
self._queue.put_nowait(item) else:
new_items.append(item)
for item in new_items:
queue.put_nowait(item)
logger.info(f"Lage {incident_id} aus Warteschlange entfernt (removed={removed})") logger.info(f"Lage {incident_id} aus Warteschlange entfernt (removed={removed})")
@@ -519,12 +564,22 @@ class AgentOrchestrator:
self._cancel_requested.discard(incident_id) self._cancel_requested.discard(incident_id)
raise asyncio.CancelledError("Vom Nutzer abgebrochen") raise asyncio.CancelledError("Vom Nutzer abgebrochen")
async def _worker(self): async def _worker(self, lane_key: int, queue: asyncio.Queue):
"""Verarbeitet Refresh-Aufträge sequentiell.""" """Verarbeitet die Auftraege EINER Organisation sequentiell. Verschiedene
Organisationen laufen in eigenen Lanes parallel. Bei Leerlauf beendet sich
die Lane selbst und wird beim naechsten Auftrag neu angelegt."""
while self._running: while self._running:
try: try:
item = await asyncio.wait_for(self._queue.get(), timeout=5.0) item = await asyncio.wait_for(queue.get(), timeout=self.IDLE_TIMEOUT)
except asyncio.TimeoutError: except asyncio.TimeoutError:
# Leerlauf: Lane beenden. Unter Lock gegen gleichzeitiges Einreihen,
# damit kein Auftrag in einer verwaisten Queue liegen bleibt.
async with self._lanes_lock:
if queue.empty() and self._lanes.get(lane_key) is queue:
self._lanes.pop(lane_key, None)
self._lane_workers.pop(lane_key, None)
logger.info(f"Worker-Lane fuer Organisation {lane_key} bei Leerlauf beendet")
return
continue continue
if len(item) == 3: if len(item) == 3:
@@ -533,12 +588,12 @@ class AgentOrchestrator:
incident_id, trigger_type = item incident_id, trigger_type = item
user_id = None user_id = None
self._queued_ids.discard(incident_id) self._queued_ids.discard(incident_id)
self._current_task = incident_id
# Session-Start EINMAL setzen — bleibt ueber Multi-Pass/Retry hinweg stabil # Session-Start EINMAL setzen — bleibt ueber Multi-Pass/Retry hinweg stabil
self._current_task_started_at = datetime.utcnow().strftime('%Y-%m-%dT%H:%M:%SZ') self._current_tasks[incident_id] = datetime.utcnow().strftime('%Y-%m-%dT%H:%M:%SZ')
self._cancel_event = asyncio.Event() cancel_event = asyncio.Event()
_cancel_event_var.set(self._cancel_event) self._cancel_events[incident_id] = cancel_event
logger.info(f"Starte Refresh für Lage {incident_id} (Trigger: {trigger_type})") _cancel_event_var.set(cancel_event)
logger.info(f"Starte Refresh für Lage {incident_id} (Lane {lane_key}, Trigger: {trigger_type})")
RETRY_DELAYS = [0, 120, 300] # Sekunden: sofort, 2min, 5min RETRY_DELAYS = [0, 120, 300] # Sekunden: sofort, 2min, 5min
TRANSIENT_ERRORS = (asyncio.TimeoutError, TimeoutError, ConnectionError, OSError) TRANSIENT_ERRORS = (asyncio.TimeoutError, TimeoutError, ConnectionError, OSError)
@@ -548,6 +603,10 @@ class AgentOrchestrator:
def _is_transient_cli(err: Exception) -> bool: def _is_transient_cli(err: Exception) -> bool:
return isinstance(err, ClaudeCliError) and err.error_type in ("rate_limit", "timeout") return isinstance(err, ClaudeCliError) and err.error_type in ("rate_limit", "timeout")
# Optionales globales Ventil (Default aus): begrenzt gleichzeitige Recherchen.
sem = self._global_sem
if sem is not None:
await sem.acquire()
try: try:
# Research-Lagen: Automatisch 3 Durchläufe nur beim ersten Refresh # Research-Lagen: Automatisch 3 Durchläufe nur beim ersten Refresh
incident_type, has_summary = await self._get_incident_info(incident_id) incident_type, has_summary = await self._get_incident_info(incident_id)
@@ -626,11 +685,13 @@ class AgentOrchestrator:
"data": {"error": str(last_error)}, "data": {"error": str(last_error)},
}, _vis, _cb, _tid) }, _vis, _cb, _tid)
finally: finally:
self._current_task = None if sem is not None:
self._current_task_started_at = None sem.release()
self._cancel_event = None self._current_tasks.pop(incident_id, None)
self._cancel_events.pop(incident_id, None)
self._cancel_requested.discard(incident_id)
_cancel_event_var.set(None) _cancel_event_var.set(None)
self._queue.task_done() queue.task_done()
async def _mark_refresh_cancelled(self, incident_id: int): async def _mark_refresh_cancelled(self, incident_id: int):
"""Markiert den laufenden Refresh-Log-Eintrag als cancelled und schliesst """Markiert den laufenden Refresh-Log-Eintrag als cancelled und schliesst

Datei anzeigen

@@ -1,4 +1,5 @@
"""Async E-Mail-Versand via SMTP.""" """Async E-Mail-Versand via SMTP."""
import asyncio
import logging import logging
from email.mime.text import MIMEText from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart from email.mime.multipart import MIMEMultipart
@@ -17,6 +18,12 @@ from config import (
logger = logging.getLogger("osint.email") logger = logging.getLogger("osint.email")
# Die IONOS-SMTP-Farm (smtp.ionos.de) besteht aus ~10 Backends im DNS-Round-Robin;
# einzelne Backends koennen tot/ueberlastet sein (beobachtet 2026-07-10). Jeder
# Versuch oeffnet eine neue Verbindung und wuerfelt damit ein neues Backend.
MAIL_VERSUCHE = 3
MAIL_TIMEOUT_S = 20
async def send_email(to_email: str, subject: str, html_body: str) -> bool: async def send_email(to_email: str, subject: str, html_body: str) -> bool:
"""Sendet eine HTML-E-Mail. """Sendet eine HTML-E-Mail.
@@ -38,17 +45,30 @@ async def send_email(to_email: str, subject: str, html_body: str) -> bool:
msg.attach(MIMEText(text_content, "plain", "utf-8")) msg.attach(MIMEText(text_content, "plain", "utf-8"))
msg.attach(MIMEText(html_body, "html", "utf-8")) msg.attach(MIMEText(html_body, "html", "utf-8"))
try: letzter_fehler = None
await aiosmtplib.send( for versuch in range(1, MAIL_VERSUCHE + 1):
msg, try:
hostname=SMTP_HOST, await aiosmtplib.send(
port=SMTP_PORT, msg,
username=SMTP_USER if SMTP_USER else None, hostname=SMTP_HOST,
password=SMTP_PASSWORD if SMTP_PASSWORD else None, port=SMTP_PORT,
start_tls=SMTP_USE_TLS, username=SMTP_USER if SMTP_USER else None,
) password=SMTP_PASSWORD if SMTP_PASSWORD else None,
logger.info(f"E-Mail gesendet an {to_email}: {subject}") start_tls=SMTP_USE_TLS,
return True timeout=MAIL_TIMEOUT_S,
except Exception as e: )
logger.error(f"E-Mail-Versand fehlgeschlagen an {to_email}: {e}") logger.info(f"E-Mail gesendet an {to_email}: {subject}")
return False return True
except Exception as e:
letzter_fehler = e
logger.warning(
f"E-Mail-Versand Versuch {versuch}/{MAIL_VERSUCHE} an {to_email} fehlgeschlagen: {e}"
)
if versuch < MAIL_VERSUCHE:
await asyncio.sleep(1.5)
logger.error(
f"E-Mail-Versand endgueltig fehlgeschlagen an {to_email} "
f"nach {MAIL_VERSUCHE} Versuchen: {letzter_fehler}"
)
return False

Datei anzeigen

@@ -7,6 +7,7 @@ import re
import uuid import uuid
from collections import defaultdict from collections import defaultdict
from datetime import datetime from datetime import datetime
from html import escape as _html_escape
from pathlib import Path from pathlib import Path
import pikepdf import pikepdf
@@ -153,6 +154,66 @@ def _markdown_to_html(text: str) -> str:
return '\n'.join(result) 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: def _truncate_lagebild(summary_text: str, max_chars: int = 4000) -> str:
"""Lagebild für den Lagebericht auf die Zusammenfassung kürzen. """Lagebild für den Lagebericht auf die Zusammenfassung kürzen.
@@ -479,6 +540,10 @@ def _build_export_metadata(
subject = (incident.get("description") or "").strip() subject = (incident.get("description") or "").strip()
if not subject: if not subject:
subject = f"{type_label} zu: {title_raw}" 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 sammeln (Reihenfolge relevant für Anzeige, Dedup mit dict.fromkeys)
keywords: list[str] = ["OSINT", type_label] keywords: list[str] = ["OSINT", type_label]
@@ -711,22 +776,33 @@ async def generate_pdf(
else: # full else: # full
sections = {"zusammenfassung", "bericht", "faktencheck", "quellen", "timeline"} 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" is_research = incident.get("type") == "research"
all_sources = _prepare_sources(incident) all_sources = _prepare_sources(incident)
latest_dev = (incident.get("latest_developments") or "").strip()
zusammenfassung_html = executive_summary_html zusammenfassung_html = executive_summary_html
bericht_summary = incident.get("summary", "") bericht_summary = incident.get("summary", "")
zusammenfassung_title = "Zusammenfassung" zusammenfassung_title = "Zusammenfassung"
summary_has_links = True
if is_research and bericht_summary: if is_research and bericht_summary:
extracted_html, remaining = _extract_zusammenfassung(bericht_summary, all_sources) extracted_html, remaining = _extract_zusammenfassung(bericht_summary, all_sources)
if extracted_html: if extracted_html:
zusammenfassung_html = extracted_html zusammenfassung_html = extracted_html
zusammenfassung_title = "Zusammenfassung"
bericht_summary = remaining 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 # KI-/Research-Zusammenfassung linkifizieren; Developments bleiben linkfrei
if not is_research and zusammenfassung_html: if not is_research and summary_has_links and zusammenfassung_html:
zusammenfassung_html = _linkify_citations_html(zusammenfassung_html, all_sources) zusammenfassung_html = _linkify_citations_html(zusammenfassung_html, all_sources)
meta = _build_export_metadata( meta = _build_export_metadata(
@@ -799,20 +875,27 @@ async def generate_docx(
else: # full else: # full
sections = {"zusammenfassung", "bericht", "faktencheck", "quellen", "timeline"} 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" is_research = incident.get("type") == "research"
all_sources = _prepare_sources(incident) all_sources = _prepare_sources(incident)
latest_dev = (incident.get("latest_developments") or "").strip()
zusammenfassung_text = executive_summary_text zusammenfassung_text = executive_summary_text
bericht_summary = incident.get("summary") or "Keine Zusammenfassung verfügbar." bericht_summary = incident.get("summary") or "Keine Zusammenfassung verfügbar."
zusammenfassung_title = "Zusammenfassung" zusammenfassung_title = "Zusammenfassung"
zusammenfassung_lines: list[str] = [] zusammenfassung_lines: list[str] = []
zusammenfassung_developments: list[tuple[str, str]] = []
if is_research and bericht_summary: if is_research and bericht_summary:
extracted_lines, remaining = _extract_zusammenfassung_lines(bericht_summary) extracted_lines, remaining = _extract_zusammenfassung_lines(bericht_summary)
if extracted_lines: if extracted_lines:
zusammenfassung_lines = extracted_lines zusammenfassung_lines = extracted_lines
zusammenfassung_title = "Zusammenfassung"
bericht_summary = remaining 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( meta = _build_export_metadata(
incident, articles, fact_checks, all_sources, creator, scope, sections, incident, articles, fact_checks, all_sources, creator, scope, sections,
@@ -890,11 +973,23 @@ async def generate_docx(
doc.add_page_break() doc.add_page_break()
# --- Zusammenfassung / Executive Summary --- # --- Zusammenfassung / Neueste Entwicklungen ---
if "zusammenfassung" in sections: if "zusammenfassung" in sections:
doc.add_heading(zusammenfassung_title, level=1) 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: for line in zusammenfassung_lines:
_add_docx_paragraph_with_citations(doc, line, all_sources, style='List Bullet') _add_docx_paragraph_with_citations(doc, line, all_sources, style='List Bullet')
else: 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 ul { margin: 8px 0 0 18px; }
.exec-summary li { margin-bottom: 6px; line-height: 1.6; } .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 */
.lagebild-content { line-height: 1.7; } .lagebild-content { line-height: 1.7; }
.lagebild-content p { margin-bottom: 8px; } .lagebild-content p { margin-bottom: 8px; }
@@ -99,7 +105,7 @@ tr:nth-child(even) { background: #f8f9fa; }
<div class="toc"> <div class="toc">
<h2>Inhaltsverzeichnis</h2> <h2>Inhaltsverzeichnis</h2>
<ul class="toc-list"> <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 '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 '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 %} {% if 'quellen' in sections and sources %}<li><a href="#sec-quellen">Quellenverzeichnis</a></li>{% endif %}

Datei anzeigen

@@ -165,28 +165,25 @@ async def get_refreshing_incidents(
) )
rows = await cursor.fetchall() rows = await cursor.fetchall()
# Also include queued incidents from orchestrator # Queued- und laufende Lagen aus dem Orchestrator ergaenzen.
from agents.orchestrator import orchestrator from agents.orchestrator import orchestrator
queued_ids = list(orchestrator._queued_ids) if hasattr(orchestrator, '_queued_ids') else [] queued_ids = list(getattr(orchestrator, '_queued_ids', set()))
current_task = orchestrator._current_task if hasattr(orchestrator, '_current_task') else None # incident_id -> stabiler Session-Start (ueber Multi-Pass/Retry hinweg). Ersetzt
# Session-Start des aktuell laufenden Tasks — stabil ueber Multi-Pass/Retry hinweg. # den frueheren Einzelwert, da jetzt mehrere Organisationen parallel laufen.
# Verhindert, dass der Frontend-Timer beim Reload auf den letzten Log-Eintrag current_tasks = getattr(orchestrator, '_current_tasks', {}) or {}
# (pass 2/3 oder retry n) zurueckspringt.
current_started_at = (
orchestrator._current_task_started_at
if hasattr(orchestrator, '_current_task_started_at') else None
)
details = {} details = {}
for row in rows: for row in rows:
iid = row["incident_id"] iid = row["incident_id"]
started_at = ( session_start = current_tasks.get(iid)
current_started_at started_at = session_start if session_start else row["started_at"]
if (iid == current_task and current_started_at)
else row["started_at"]
)
details[str(iid)] = {"started_at": started_at} details[str(iid)] = {"started_at": started_at}
# Pro Organisation laeuft hoechstens eine Lage gleichzeitig; der Endpoint ist
# ohnehin tenant-gefiltert, daher genuegt der erste laufende Treffer.
running_here = [row["incident_id"] for row in rows if row["incident_id"] in current_tasks]
current_task = running_here[0] if running_here else None
return { return {
"refreshing": [row["incident_id"] for row in rows], "refreshing": [row["incident_id"] for row in rows],
"queued": queued_ids, "queued": queued_ids,
@@ -1232,18 +1229,14 @@ async def export_incident(
snapshots = [dict(r) for r in await cursor.fetchall()] snapshots = [dict(r) for r in await cursor.fetchall()]
# Zusammenfassung fuer den Export: # Zusammenfassung fuer den Export:
# - Bei Adhoc-Lagen primaer "Neueste Entwicklungen" (latest_developments) als Markdown-Bullets, # - Live-Monitoring (adhoc) zeigt primaer "Neueste Entwicklungen" (latest_developments).
# weil Live-Monitoring von Aktualitaet lebt. # Das Rendering (Datum/Uhrzeit als eigene Zeile, ohne Links) uebernimmt der
# - Fallback (oder bei Research): Executive Summary (KI-generiert, gecacht). # 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" is_adhoc = (incident.get("type") or "adhoc") != "research"
latest_dev = (incident.get("latest_developments") or "").strip() latest_dev = (incident.get("latest_developments") or "").strip()
exec_summary = None exec_summary = incident.get("executive_summary")
if is_adhoc and latest_dev: if not exec_summary and not (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:
summary_text = incident.get("summary") or "" summary_text = incident.get("summary") or ""
exec_summary = await generate_executive_summary(summary_text) exec_summary = await generate_executive_summary(summary_text)
await db.execute( await db.execute(
@@ -1251,6 +1244,7 @@ async def export_incident(
(exec_summary, incident_id), (exec_summary, incident_id),
) )
await db.commit() await db.commit()
exec_summary = exec_summary or ""
date_str = datetime.now(TIMEZONE).strftime("%Y%m%d") date_str = datetime.now(TIMEZONE).strftime("%Y%m%d")
slug = _slugify(incident["title"]) slug = _slugify(incident["title"])

Datei anzeigen

@@ -352,6 +352,16 @@
</div> </div>
<form id="new-incident-form"> <form id="new-incident-form">
<div class="modal-body"> <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"> <div class="form-group">
<label for="inc-title" data-i18n="modal.new_incident.title_field">Titel des Vorfalls</label> <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"> <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> <textarea id="inc-description" placeholder="Weitere Details zum Vorfall (optional)" data-i18n-attr="placeholder:modal.placeholder.description"></textarea>
</div> </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"> <div class="form-group">
<label data-i18n="modal.field.sources">Quellen</label> <label data-i18n="modal.field.sources">Quellen</label>
<div class="toggle-group"> <div class="toggle-group">
@@ -420,7 +420,7 @@
<div class="form-group conditional-field" id="refresh-interval-field"> <div class="form-group conditional-field" id="refresh-interval-field">
<label for="inc-refresh-value" data-i18n="modal.field.interval">Intervall</label> <label for="inc-refresh-value" data-i18n="modal.field.interval">Intervall</label>
<div class="interval-input-group"> <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()"> <select id="inc-refresh-unit" onchange="updateIntervalMin()">
<option value="1" selected data-i18n="modal.unit.minutes">Minuten</option> <option value="1" selected data-i18n="modal.unit.minutes">Minuten</option>
<option value="60" data-i18n="modal.unit.hours">Stunden</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> <option value="10080" data-i18n="modal.unit.weeks">Wochen</option>
</select> </select>
</div> </div>
<div class="form-hint" id="interval-min-hint" style="display:none;"></div>
</div> </div>
<div class="form-group conditional-field" id="refresh-starttime-field"> <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> <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 // Telegram-Kategorien Toggle
const tgCheckbox = document.getElementById('inc-telegram'); const tgCheckbox = document.getElementById('inc-telegram');
if (tgCheckbox) { 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 // Feedback
@@ -1836,9 +1844,9 @@ const App = {
// === Event Handlers === // === Event Handlers ===
_getFormData() { _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 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 { return {
title: document.getElementById('inc-title').value.trim(), title: document.getElementById('inc-title').value.trim(),
description: document.getElementById('inc-description').value.trim() || null, description: document.getElementById('inc-description').value.trim() || null,
@@ -2294,6 +2302,7 @@ async handleRefresh() {
updateSourcesHint(); updateSourcesHint();
toggleTypeDefaults(true); toggleTypeDefaults(true);
toggleRefreshInterval(); toggleRefreshInterval();
updateIntervalMin();
// Modal-Titel und Submit ändern // 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'; } { 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; document.getElementById('inc-notify-status-change').checked = false;
toggleTypeDefaults(); toggleTypeDefaults();
toggleRefreshInterval(); toggleRefreshInterval();
updateIntervalMin();
} }
const modal = document.getElementById(id); const modal = document.getElementById(id);
modal._previousFocus = document.activeElement; modal._previousFocus = document.activeElement;
@@ -3814,17 +3824,38 @@ function toggleRefreshInterval() {
if (startField) startField.classList.toggle('visible', mode === 'auto'); 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() { function updateIntervalMin() {
const unit = parseInt(document.getElementById('inc-refresh-unit').value); const unit = parseInt(document.getElementById('inc-refresh-unit').value);
const input = document.getElementById('inc-refresh-value'); const input = document.getElementById('inc-refresh-value');
const minMinutes = _getMinIntervalMinutes();
const hint = document.getElementById('interval-min-hint');
if (unit === 1) { if (unit === 1) {
// Minuten: Minimum 10 // Minuten: dynamisches Minimum (30 / 45 bei X oder Telegram / 60 bei beiden)
input.min = 10; input.min = minMinutes;
if (parseInt(input.value) < 10) input.value = 10; 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 { } else {
// Stunden/Tage/Wochen: Minimum 1 // Stunden/Tage/Wochen: eine Einheit liegt ueber jedem Minuten-Minimum
input.min = 1; 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';
} }
} }