Commits vergleichen
15 Commits
archive/de
...
70133188a6
| Autor | SHA1 | Datum | |
|---|---|---|---|
| 70133188a6 | |||
| 31e885254a | |||
| db6f847daf | |||
|
|
e3b4e25429 | ||
|
|
3a3076c4cf | ||
| e0107a1bb1 | |||
|
|
d32746b00f | ||
| 7d9bca12ee | |||
|
|
3e64539aa3 | ||
|
|
1647a6f50a | ||
|
|
c53e260c6c | ||
| c3a0ee4538 | |||
| aa36a9a38f | |||
| b02578e48b | |||
| 38ce26f0be |
@@ -1,4 +1,18 @@
|
|||||||
[
|
[
|
||||||
|
{
|
||||||
|
"version": "2026-07-23T21:10Z",
|
||||||
|
"date": "2026-07-23",
|
||||||
|
"title": "Header bleibt während der Suche bedienbar",
|
||||||
|
"items": [
|
||||||
|
"Der Header ist jetzt auch während einer laufenden Recherche vollständig bedienbar."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "2026-07-23T20:59Z",
|
||||||
|
"date": "2026-07-23",
|
||||||
|
"title": "Fix Headerzeilenbedienbarkeit beim ersten Falldurchlauf",
|
||||||
|
"items": []
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"version": "2026-05-22T19:10Z",
|
"version": "2026-05-22T19:10Z",
|
||||||
"date": "2026-05-22",
|
"date": "2026-05-22",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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"])
|
||||||
|
|||||||
@@ -2144,6 +2144,14 @@ a.dev-source-pill:hover {
|
|||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
background: rgba(0,0,0,0.15);
|
background: rgba(0,0,0,0.15);
|
||||||
}
|
}
|
||||||
|
/* Der globale Header bleibt auch während der ersten (blockierenden) Recherche
|
||||||
|
bedienbar. Das Overlay deckt sonst den gesamten Viewport ab und schluckt die
|
||||||
|
Klicks auf Barrierefreiheit, Theme-Wechsel, Konto-Menü und Abmelden.
|
||||||
|
Gesperrt bleiben ausschließlich die fallbezogenen Aktionen (Aktualisieren,
|
||||||
|
Bearbeiten, ...) über #incident-view.refresh-blurred. */
|
||||||
|
body.first-refresh-blocking .header {
|
||||||
|
z-index: 9100;
|
||||||
|
}
|
||||||
.progress-popup {
|
.progress-popup {
|
||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
background: var(--bg-primary);
|
background: var(--bg-primary);
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
<link rel="stylesheet" href="/static/vendor/leaflet.css">
|
<link rel="stylesheet" href="/static/vendor/leaflet.css">
|
||||||
<link rel="stylesheet" href="/static/vendor/MarkerCluster.css">
|
<link rel="stylesheet" href="/static/vendor/MarkerCluster.css">
|
||||||
<link rel="stylesheet" href="/static/vendor/MarkerCluster.Default.css">
|
<link rel="stylesheet" href="/static/vendor/MarkerCluster.Default.css">
|
||||||
<link rel="stylesheet" href="/static/css/style.css?v=20260522c">
|
<link rel="stylesheet" href="/static/css/style.css?v=20260723a">
|
||||||
<style>
|
<style>
|
||||||
/* Export Modal Radio */
|
/* Export Modal Radio */
|
||||||
.export-radio { display:flex; align-items:center; gap:10px; padding:8px 12px; cursor:pointer; border-radius:var(--radius-sm); transition:background 0.15s; border:1px solid transparent; margin-bottom:4px; }
|
.export-radio { display:flex; align-items:center; gap:10px; padding:8px 12px; cursor:pointer; border-radius:var(--radius-sm); transition:background 0.15s; border:1px solid transparent; margin-bottom:4px; }
|
||||||
@@ -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>
|
||||||
@@ -807,10 +808,10 @@
|
|||||||
<script src="/static/js/i18n.js?v=20260513a"></script>
|
<script src="/static/js/i18n.js?v=20260513a"></script>
|
||||||
<script src="/static/js/api.js?v=20260522f"></script>
|
<script src="/static/js/api.js?v=20260522f"></script>
|
||||||
<script src="/static/js/ws.js?v=20260316b"></script>
|
<script src="/static/js/ws.js?v=20260316b"></script>
|
||||||
<script src="/static/js/components.js?v=20260522d"></script>
|
<script src="/static/js/components.js?v=20260723a"></script>
|
||||||
<script src="/static/js/layout.js?v=20260513f"></script>
|
<script src="/static/js/layout.js?v=20260513f"></script>
|
||||||
<script src="/static/js/pipeline.js?v=20260513d"></script>
|
<script src="/static/js/pipeline.js?v=20260513d"></script>
|
||||||
<script src="/static/js/app.js?v=20260522f"></script>
|
<script src="/static/js/app.js?v=20260723a"></script>
|
||||||
<script src="/static/js/cluster-data.js?v=20260322f"></script>
|
<script src="/static/js/cluster-data.js?v=20260322f"></script>
|
||||||
<script src="/static/js/tutorial.js?v=20260316z"></script>
|
<script src="/static/js/tutorial.js?v=20260316z"></script>
|
||||||
<script src="/static/js/chat.js?v=20260514e"></script>
|
<script src="/static/js/chat.js?v=20260514e"></script>
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -776,6 +784,7 @@ const App = {
|
|||||||
// Hide any popup/mini from previous incident
|
// Hide any popup/mini from previous incident
|
||||||
const prevOverlay = document.getElementById('progress-overlay');
|
const prevOverlay = document.getElementById('progress-overlay');
|
||||||
if (prevOverlay) prevOverlay.style.display = 'none';
|
if (prevOverlay) prevOverlay.style.display = 'none';
|
||||||
|
if (typeof UI !== 'undefined' && UI._syncHeaderAccess) UI._syncHeaderAccess();
|
||||||
const prevMini = document.getElementById('progress-mini');
|
const prevMini = document.getElementById('progress-mini');
|
||||||
if (prevMini) prevMini.style.display = 'none';
|
if (prevMini) prevMini.style.display = 'none';
|
||||||
const blurTarget = document.getElementById('incident-view');
|
const blurTarget = document.getElementById('incident-view');
|
||||||
@@ -1836,9 +1845,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 +2303,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'; }
|
||||||
@@ -2587,6 +2597,7 @@ async handleRefresh() {
|
|||||||
// Temporarily hide progress popup so confirm dialog is fully visible
|
// Temporarily hide progress popup so confirm dialog is fully visible
|
||||||
const progressOverlay = document.getElementById('progress-overlay');
|
const progressOverlay = document.getElementById('progress-overlay');
|
||||||
if (progressOverlay) progressOverlay.style.display = 'none';
|
if (progressOverlay) progressOverlay.style.display = 'none';
|
||||||
|
UI._syncHeaderAccess();
|
||||||
|
|
||||||
const ok = await confirmDialog((typeof T === 'function' ? T('confirm.cancel_running_research', 'Laufende Recherche abbrechen?') : 'Laufende Recherche abbrechen?'));
|
const ok = await confirmDialog((typeof T === 'function' ? T('confirm.cancel_running_research', 'Laufende Recherche abbrechen?') : 'Laufende Recherche abbrechen?'));
|
||||||
|
|
||||||
@@ -2594,11 +2605,13 @@ async handleRefresh() {
|
|||||||
if (!ok) {
|
if (!ok) {
|
||||||
const state = UI._progressState[this.currentIncidentId];
|
const state = UI._progressState[this.currentIncidentId];
|
||||||
if (state && progressOverlay) progressOverlay.style.display = 'flex';
|
if (state && progressOverlay) progressOverlay.style.display = 'flex';
|
||||||
|
UI._syncHeaderAccess();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show cancelling state in popup
|
// Show cancelling state in popup
|
||||||
if (progressOverlay) progressOverlay.style.display = 'flex';
|
if (progressOverlay) progressOverlay.style.display = 'flex';
|
||||||
|
UI._syncHeaderAccess();
|
||||||
const btn = document.getElementById('progress-cancel-btn');
|
const btn = document.getElementById('progress-cancel-btn');
|
||||||
if (btn) {
|
if (btn) {
|
||||||
btn.textContent = (typeof T === 'function' ? T('action.cancelling', 'Wird abgebrochen...') : 'Wird abgebrochen...');
|
btn.textContent = (typeof T === 'function' ? T('action.cancelling', 'Wird abgebrochen...') : 'Wird abgebrochen...');
|
||||||
@@ -3633,6 +3646,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 +3828,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';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -355,6 +355,20 @@ const UI = {
|
|||||||
this._showPopupProgress(status, extra, state);
|
this._showPopupProgress(status, extra, state);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hält den globalen Header bedienbar, solange das blockierende
|
||||||
|
* Fortschritts-Overlay sichtbar ist. Die Body-Klasse hebt den Header per
|
||||||
|
* z-index über das Overlay: Barrierefreiheit, Theme, Konto-Menü und
|
||||||
|
* Abmelden müssen jederzeit erreichbar sein.
|
||||||
|
*/
|
||||||
|
_syncHeaderAccess() {
|
||||||
|
const overlay = document.getElementById('progress-overlay');
|
||||||
|
const blocking = !!overlay
|
||||||
|
&& overlay.classList.contains('blocking')
|
||||||
|
&& overlay.style.display !== 'none';
|
||||||
|
document.body.classList.toggle('first-refresh-blocking', blocking);
|
||||||
|
},
|
||||||
|
|
||||||
_showPopupProgress(status, extra, state) {
|
_showPopupProgress(status, extra, state) {
|
||||||
const overlay = document.getElementById('progress-overlay');
|
const overlay = document.getElementById('progress-overlay');
|
||||||
const popup = document.getElementById('progress-popup');
|
const popup = document.getElementById('progress-popup');
|
||||||
@@ -381,6 +395,7 @@ const UI = {
|
|||||||
} else {
|
} else {
|
||||||
overlay.classList.remove('blocking');
|
overlay.classList.remove('blocking');
|
||||||
}
|
}
|
||||||
|
this._syncHeaderAccess();
|
||||||
|
|
||||||
// Minimize button: only for updates (not first)
|
// Minimize button: only for updates (not first)
|
||||||
const minBtn = document.getElementById('progress-popup-minimize');
|
const minBtn = document.getElementById('progress-popup-minimize');
|
||||||
@@ -485,6 +500,7 @@ const UI = {
|
|||||||
// Hide popup
|
// Hide popup
|
||||||
const overlay = document.getElementById('progress-overlay');
|
const overlay = document.getElementById('progress-overlay');
|
||||||
if (overlay) overlay.style.display = 'none';
|
if (overlay) overlay.style.display = 'none';
|
||||||
|
this._syncHeaderAccess();
|
||||||
},
|
},
|
||||||
|
|
||||||
minimizeProgress(incidentId) {
|
minimizeProgress(incidentId) {
|
||||||
@@ -528,6 +544,7 @@ const UI = {
|
|||||||
overlay.style.display = 'flex';
|
overlay.style.display = 'flex';
|
||||||
overlay.classList.remove('blocking');
|
overlay.classList.remove('blocking');
|
||||||
}
|
}
|
||||||
|
this._syncHeaderAccess();
|
||||||
|
|
||||||
// Mark all steps done
|
// Mark all steps done
|
||||||
document.querySelectorAll('.progress-check-item').forEach(item => {
|
document.querySelectorAll('.progress-check-item').forEach(item => {
|
||||||
@@ -620,6 +637,7 @@ const UI = {
|
|||||||
if (incidentId === App.currentIncidentId) {
|
if (incidentId === App.currentIncidentId) {
|
||||||
const overlay = document.getElementById('progress-overlay');
|
const overlay = document.getElementById('progress-overlay');
|
||||||
if (overlay) { overlay.style.display = 'none'; overlay.classList.remove('blocking'); }
|
if (overlay) { overlay.style.display = 'none'; overlay.classList.remove('blocking'); }
|
||||||
|
this._syncHeaderAccess();
|
||||||
const mini = document.getElementById('progress-mini');
|
const mini = document.getElementById('progress-mini');
|
||||||
if (mini) mini.style.display = 'none';
|
if (mini) mini.style.display = 'none';
|
||||||
}
|
}
|
||||||
|
|||||||
In neuem Issue referenzieren
Einen Benutzer sperren