Export: Klassifizierung (offen/dienstgebrauch/vertraulich) komplett entfernt

Dieser Commit ist enthalten in:
Claude Dev
2026-03-25 23:50:57 +01:00
Ursprung a2aaa061d4
Commit d0f99f4e5b
12 geänderte Dateien mit 5634 neuen und 47 gelöschten Zeilen

Datei anzeigen

@@ -22,11 +22,6 @@ logger = logging.getLogger("osint.report")
TEMPLATE_DIR = Path(__file__).parent / "report_templates"
LOGO_PATH = Path(__file__).parent / "static" / "favicon.svg"
CLASSIFICATION_LABELS = {
"offen": "Offen",
"dienstgebrauch": "Nur für den Dienstgebrauch",
"vertraulich": "Vertraulich",
}
FC_STATUS_LABELS = {
"confirmed": "Bestätigt",
@@ -250,7 +245,7 @@ LAGEBILD:
async def generate_pdf(
incident: dict, articles: list, fact_checks: list, snapshots: list,
scope: str, classification: str, creator: str, executive_summary_html: str,
scope: str, creator: str, executive_summary_html: str,
) -> bytes:
"""PDF-Report via WeasyPrint generieren."""
env = Environment(loader=FileSystemLoader(str(TEMPLATE_DIR)))
@@ -262,8 +257,6 @@ async def generate_pdf(
html_content = template.render(
incident=incident,
incident_type_label=incident_type_label,
classification=classification,
classification_label=CLASSIFICATION_LABELS.get(classification, classification),
report_date=now.strftime("%d.%m.%Y, %H:%M Uhr"),
creator=creator,
logo_base64=_get_logo_base64(),
@@ -298,7 +291,7 @@ async def generate_pdf(
async def generate_docx(
incident: dict, articles: list, fact_checks: list, snapshots: list,
scope: str, classification: str, creator: str, executive_summary_text: str,
scope: str, creator: str, executive_summary_text: str,
) -> bytes:
"""Word-Report via python-docx generieren."""
doc = Document()
@@ -342,17 +335,6 @@ async def generate_docx(
run.font.color.rgb = RGBColor(0x66, 0x66, 0x66)
doc.add_paragraph()
# Klassifizierung
class_label = CLASSIFICATION_LABELS.get(classification, classification)
class_para = doc.add_paragraph()
class_para.alignment = WD_ALIGN_PARAGRAPH.CENTER
run = class_para.add_run(f"{class_label}")
run.font.size = Pt(11)
run.font.bold = True
colors = {"offen": RGBColor(0x22, 0xc5, 0x5e), "dienstgebrauch": RGBColor(0xf0, 0xb4, 0x29), "vertraulich": RGBColor(0xef, 0x44, 0x44)}
run.font.color.rgb = colors.get(classification, RGBColor(0x88, 0x88, 0x88))
for _ in range(3):
doc.add_paragraph()

485
src/report_generator.py.bak Normale Datei
Datei anzeigen

@@ -0,0 +1,485 @@
"""Report-Generator: PDF und Word Berichte aus Lage-Daten."""
import base64
import io
import json
import logging
import re
from collections import defaultdict
from datetime import datetime
from pathlib import Path
from jinja2 import Environment, FileSystemLoader
from weasyprint import HTML
from docx import Document
from docx.shared import Inches, Pt, Cm, RGBColor
from docx.enum.text import WD_ALIGN_PARAGRAPH
from docx.enum.table import WD_TABLE_ALIGNMENT
from config import TIMEZONE, CLAUDE_MODEL_FAST
logger = logging.getLogger("osint.report")
TEMPLATE_DIR = Path(__file__).parent / "report_templates"
LOGO_PATH = Path(__file__).parent / "static" / "favicon.svg"
CLASSIFICATION_LABELS = {
"offen": "Offen",
"dienstgebrauch": "Nur für den Dienstgebrauch",
"vertraulich": "Vertraulich",
}
FC_STATUS_LABELS = {
"confirmed": "Bestätigt",
"unconfirmed": "Unbestätigt",
"disputed": "Umstritten",
"false": "Falsch",
}
def _get_logo_base64() -> str:
"""Logo als Base64 für HTML-Embedding."""
try:
return base64.b64encode(LOGO_PATH.read_bytes()).decode()
except Exception:
return ""
def _prepare_sources(incident: dict) -> list:
"""Quellenverzeichnis aus sources_json parsen."""
raw = incident.get("sources_json")
if not raw:
return []
try:
return json.loads(raw) if isinstance(raw, str) else raw
except (json.JSONDecodeError, TypeError):
return []
def _prepare_source_stats(articles: list) -> list:
"""Quellenstatistik: Artikel pro Quelle + Sprachen."""
source_map = defaultdict(lambda: {"count": 0, "langs": set()})
for art in articles:
name = art.get("source") or "Unbekannt"
source_map[name]["count"] += 1
source_map[name]["langs"].add((art.get("language") or "de").upper())
stats = []
for name, data in sorted(source_map.items(), key=lambda x: -x[1]["count"]):
stats.append({"name": name, "count": data["count"], "languages": ", ".join(sorted(data["langs"]))})
return stats
def _prepare_fact_checks(fact_checks: list) -> list:
"""Faktenchecks mit Label aufbereiten."""
result = []
for fc in fact_checks:
fc_copy = dict(fc)
fc_copy["status_label"] = FC_STATUS_LABELS.get(fc.get("status", ""), fc.get("status", "Unbekannt"))
result.append(fc_copy)
return result
def _prepare_timeline(articles: list) -> list:
"""Timeline aus Artikeln: sortiert nach Datum."""
timeline = []
for art in articles:
pub = art.get("published_at") or art.get("collected_at") or ""
headline = art.get("headline_de") or art.get("headline") or "Ohne Titel"
source = art.get("source") or ""
if pub:
try:
dt = datetime.fromisoformat(pub.replace("Z", "+00:00"))
date_str = dt.strftime("%d.%m.%Y %H:%M")
except Exception:
date_str = pub[:16]
else:
date_str = ""
timeline.append({"date": date_str, "headline": headline, "source": source, "sort_key": pub})
timeline.sort(key=lambda x: x["sort_key"], reverse=True)
return timeline[:100] # Max 100 Einträge
def _markdown_to_html(text: str) -> str:
"""Einfache Markdown -> HTML Konvertierung für Lagebild."""
if not text:
return "<p><em>Kein Lagebild verfügbar.</em></p>"
# Basic Markdown -> HTML
html = text
# Headlines
html = re.sub(r'^### (.+)$', r'<h3>\1</h3>', html, flags=re.MULTILINE)
html = re.sub(r'^## (.+)$', r'<h3>\1</h3>', html, flags=re.MULTILINE)
# Bold
html = re.sub(r'\*\*(.+?)\*\*', r'<strong>\1</strong>', html)
# Links [text](url)
html = re.sub(r'\[([^\]]+)\]\(([^)]+)\)', r'<a href="\2">\1</a>', html)
# Bullet lists
html = re.sub(r'^- (.+)$', r'<li>\1</li>', html, flags=re.MULTILINE)
html = re.sub(r'(<li>.*</li>\n?)+', lambda m: '<ul>' + m.group(0) + '</ul>', html)
# Paragraphs
paragraphs = html.split('\n\n')
result = []
for p in paragraphs:
p = p.strip()
if not p:
continue
if p.startswith('<h') or p.startswith('<ul') or p.startswith('<ol'):
result.append(p)
else:
result.append(f'<p>{p}</p>')
return '\n'.join(result)
def _truncate_lagebild(summary_text: str, max_chars: int = 4000) -> str:
"""Lagebild für den Lagebericht auf die Zusammenfassung kürzen.
Nimmt nur den ersten Abschnitt (bis zur zweiten H2/H3-Überschrift)
oder kürzt auf max_chars Zeichen mit sauberem Abbruch am Absatzende.
"""
if not summary_text or len(summary_text) <= max_chars:
return summary_text
lines = summary_text.split("\n")
result_lines = []
heading_count = 0
char_count = 0
for line in lines:
stripped = line.strip()
# Zähle Überschriften (## oder ###)
if stripped.startswith("## ") or stripped.startswith("### "):
heading_count += 1
# Nach der 3. Überschrift abbrechen (= 2 Abschnitte)
if heading_count > 3:
break
result_lines.append(line)
char_count += len(line) + 1
# Hard-Limit bei max_chars, aber am Absatzende abbrechen
if char_count > max_chars and stripped == "":
break
text = "\n".join(result_lines).rstrip()
if len(text) < len(summary_text) - 100:
text += "\n\n*[Vollständiges Lagebild im Vollständigen Bericht]*"
return text
def _strip_citation_numbers(text: str) -> str:
"""Entfernt [1234]-Quellenreferenzen aus dem Text."""
# Einzelne Referenzen: [1302]
text = re.sub(r"\s*\[\d{1,5}\]", "", text)
# Mehrfach-Referenzen: [725][765][768]
text = re.sub(r"(\[\d{1,5}\]){2,}", "", text)
# Aufräumen: Doppelte Leerzeichen
text = re.sub(r" +", " ", text)
return text
async def generate_executive_summary(summary_text: str) -> str:
"""KI-verdichtetes Executive Summary aus dem Lagebild."""
if not summary_text or len(summary_text.strip()) < 50:
return "<ul><li>Kein Lagebild verfügbar. Executive Summary kann nicht erstellt werden.</li></ul>"
from agents.claude_client import call_claude
prompt = f"""Du bist ein Intelligence-Analyst für ein OSINT-Lagemonitoring-System.
Verdichte das folgende Lagebild auf genau 3-5 Kernpunkte.
REGELN:
- Jeder Punkt: 1-2 Sätze, faktenbasiert
- Fokus: Was ist passiert? Was bedeutet es? Was ist die aktuelle Dynamik?
- Sprache: Deutsch, sachlich, prägnant
- Format: Gib NUR die Bullet Points aus, einen pro Zeile, mit "- " am Anfang
- KEINE Einleitung, KEINE Überschrift, NUR die Punkte
LAGEBILD:
{summary_text}"""
try:
result, usage = await call_claude(prompt, tools=None, model=CLAUDE_MODEL_FAST)
# Robuster Parser: Akzeptiert JSON, Markdown-Listen oder Freitext
lines = []
text = result.strip()
# Code-Fences entfernen (```json ... ```)
if text.startswith("```"):
text = re.sub(r"^```\w*\n?", "", text)
text = re.sub(r"\n?```$", "", text)
text = text.strip()
# Fall 1: JSON-Antwort (Haiku gibt manchmal JSON zurück)
if text.startswith("{"):
try:
data = json.loads(text)
for key in data:
if isinstance(data[key], list):
for item in data[key]:
clean = str(item).strip().lstrip("- ").lstrip("* ")
if clean:
lines.append(clean)
break
except json.JSONDecodeError:
pass
# Fall 2: Markdown Bullet Points
if not lines:
for line in text.split("\n"):
stripped = line.strip()
if stripped.startswith(("- ", "* ")):
clean = stripped.lstrip("- ").lstrip("* ").strip()
if clean:
lines.append(clean)
# Fall 3: Nummerierte Liste (1. 2. 3.)
if not lines:
for line in text.split("\n"):
m = re.match(r"^\d+\.\s+(.+)", line.strip())
if m:
lines.append(m.group(1).strip())
# Fallback: Ganzen Text als einen Punkt
if not lines:
lines = [text[:500]]
html = "<ul>\n" + "\n".join(f"<li>{line}</li>" for line in lines if line) + "\n</ul>"
return html
except Exception as e:
logger.error(f"Executive Summary Generierung fehlgeschlagen: {e}")
return "<ul><li>Executive Summary konnte nicht generiert werden.</li></ul>"
async def generate_pdf(
incident: dict, articles: list, fact_checks: list, snapshots: list,
scope: str, classification: str, creator: str, executive_summary_html: str,
) -> bytes:
"""PDF-Report via WeasyPrint generieren."""
env = Environment(loader=FileSystemLoader(str(TEMPLATE_DIR)))
template = env.get_template("report.html")
now = datetime.now(TIMEZONE)
incident_type_label = "Hintergrundrecherche" if incident.get("type") == "research" else "Live-Monitoring"
html_content = template.render(
incident=incident,
incident_type_label=incident_type_label,
classification=classification,
classification_label=CLASSIFICATION_LABELS.get(classification, classification),
report_date=now.strftime("%d.%m.%Y, %H:%M Uhr"),
creator=creator,
logo_base64=_get_logo_base64(),
executive_summary=executive_summary_html,
scope=scope,
lagebild_html=_markdown_to_html(
_strip_citation_numbers(
_truncate_lagebild(incident.get("summary", ""), 4000) if scope == "report"
else incident.get("summary", "")
)
),
lagebild_timestamp=(incident.get("updated_at") or "")[:16].replace("T", " "),
sources=_prepare_sources(incident)[:30] if scope == "report" else _prepare_sources(incident),
fact_checks=_prepare_fact_checks(fact_checks[:20] if scope == "report" else fact_checks),
source_stats=_prepare_source_stats(articles)[:20] if scope == "report" else _prepare_source_stats(articles),
timeline=_prepare_timeline(articles) if scope == "full" else [],
articles=articles if scope == "full" else [],
)
# Artikel pub_date aufbereiten
for art in articles:
pub = art.get("published_at") or art.get("collected_at") or ""
try:
dt = datetime.fromisoformat(pub.replace("Z", "+00:00"))
art["pub_date"] = dt.strftime("%d.%m.%Y")
except Exception:
art["pub_date"] = pub[:10] if pub else ""
pdf_bytes = HTML(string=html_content).write_pdf()
return pdf_bytes
async def generate_docx(
incident: dict, articles: list, fact_checks: list, snapshots: list,
scope: str, classification: str, creator: str, executive_summary_text: str,
) -> bytes:
"""Word-Report via python-docx generieren."""
doc = Document()
# Styles
style = doc.styles['Normal']
style.font.size = Pt(10)
style.font.name = 'Calibri'
# --- Deckblatt ---
for _ in range(6):
doc.add_paragraph()
title_para = doc.add_paragraph()
title_para.alignment = WD_ALIGN_PARAGRAPH.CENTER
run = title_para.add_run("AegisSight Monitor")
run.font.size = Pt(12)
run.font.color.rgb = RGBColor(0x88, 0x88, 0x88)
doc.add_paragraph()
type_label = "Hintergrundrecherche" if incident.get("type") == "research" else "Live-Monitoring"
type_para = doc.add_paragraph()
type_para.alignment = WD_ALIGN_PARAGRAPH.CENTER
run = type_para.add_run(type_label)
run.font.size = Pt(10)
run.font.color.rgb = RGBColor(0x88, 0x88, 0x88)
title_para2 = doc.add_paragraph()
title_para2.alignment = WD_ALIGN_PARAGRAPH.CENTER
run = title_para2.add_run(incident.get("title", ""))
run.font.size = Pt(24)
run.font.bold = True
run.font.color.rgb = RGBColor(0x0a, 0x18, 0x32)
if incident.get("description"):
desc_para = doc.add_paragraph()
desc_para.alignment = WD_ALIGN_PARAGRAPH.CENTER
run = desc_para.add_run(incident["description"])
run.font.size = Pt(11)
run.font.color.rgb = RGBColor(0x66, 0x66, 0x66)
doc.add_paragraph()
# Klassifizierung
class_label = CLASSIFICATION_LABELS.get(classification, classification)
class_para = doc.add_paragraph()
class_para.alignment = WD_ALIGN_PARAGRAPH.CENTER
run = class_para.add_run(f"{class_label}")
run.font.size = Pt(11)
run.font.bold = True
colors = {"offen": RGBColor(0x22, 0xc5, 0x5e), "dienstgebrauch": RGBColor(0xf0, 0xb4, 0x29), "vertraulich": RGBColor(0xef, 0x44, 0x44)}
run.font.color.rgb = colors.get(classification, RGBColor(0x88, 0x88, 0x88))
for _ in range(3):
doc.add_paragraph()
now = datetime.now(TIMEZONE)
meta_para = doc.add_paragraph()
meta_para.alignment = WD_ALIGN_PARAGRAPH.CENTER
run = meta_para.add_run(f"Stand: {now.strftime('%d.%m.%Y, %H:%M Uhr')}\nErstellt von: {creator}")
run.font.size = Pt(9)
run.font.color.rgb = RGBColor(0x88, 0x88, 0x88)
doc.add_page_break()
# --- Executive Summary ---
doc.add_heading("Executive Summary", level=1)
# HTML-Tags entfernen und als Bullet Points
clean_text = re.sub(r'<[^>]+>', '', executive_summary_text)
lines = [line.strip().lstrip("- ").lstrip("* ") for line in clean_text.strip().split("\n") if line.strip()]
for line in lines:
if line:
doc.add_paragraph(line, style='List Bullet')
if scope in ("report", "full"):
# --- Lagebild ---
doc.add_heading("Lagebild", level=1)
raw_summary = incident.get("summary") or "Kein Lagebild verfügbar."
summary = _strip_citation_numbers(
_truncate_lagebild(raw_summary, 4000) if scope == "report" else raw_summary
)
# Markdown-Formatierung entfernen
clean_summary = re.sub(r'\*\*(.+?)\*\*', r'\1', summary)
clean_summary = re.sub(r'\[([^\]]+)\]\([^)]+\)', r'\1', clean_summary)
clean_summary = re.sub(r'^#{1,3}\s+', '', clean_summary, flags=re.MULTILINE)
for para_text in clean_summary.split("\n\n"):
para_text = para_text.strip()
if para_text:
if para_text.startswith("- "):
for bullet in para_text.split("\n"):
bullet = bullet.lstrip("- ").strip()
if bullet:
doc.add_paragraph(bullet, style='List Bullet')
else:
doc.add_paragraph(para_text)
# --- Faktencheck ---
report_fcs = fact_checks[:20] if scope == 'report' else fact_checks
if report_fcs:
doc.add_heading("Faktencheck", level=1)
table = doc.add_table(rows=1, cols=3)
table.style = 'Table Grid'
table.alignment = WD_TABLE_ALIGNMENT.CENTER
hdr = table.rows[0].cells
hdr[0].text = "Behauptung"
hdr[1].text = "Status"
hdr[2].text = "Quellen"
for cell in hdr:
for p in cell.paragraphs:
p.runs[0].font.bold = True
p.runs[0].font.size = Pt(9)
for fc in report_fcs:
row = table.add_row().cells
row[0].text = fc.get("claim", "")
row[1].text = FC_STATUS_LABELS.get(fc.get("status", ""), fc.get("status", ""))
row[2].text = str(fc.get("sources_count", 0))
# --- Quellenstatistik ---
source_stats = _prepare_source_stats(articles)
if scope == 'report':
source_stats = source_stats[:20]
if source_stats:
doc.add_heading("Quellenstatistik", level=1)
table = doc.add_table(rows=1, cols=3)
table.style = 'Table Grid'
table.alignment = WD_TABLE_ALIGNMENT.CENTER
hdr = table.rows[0].cells
hdr[0].text = "Quelle"
hdr[1].text = "Artikel"
hdr[2].text = "Sprache"
for cell in hdr:
for p in cell.paragraphs:
p.runs[0].font.bold = True
p.runs[0].font.size = Pt(9)
for stat in source_stats:
row = table.add_row().cells
row[0].text = stat["name"]
row[1].text = str(stat["count"])
row[2].text = stat["languages"]
if scope == "full":
# --- Artikelverzeichnis ---
if articles:
doc.add_page_break()
doc.add_heading(f"Artikelverzeichnis ({len(articles)} Artikel)", level=1)
table = doc.add_table(rows=1, cols=4)
table.style = 'Table Grid'
table.alignment = WD_TABLE_ALIGNMENT.CENTER
hdr = table.rows[0].cells
for i, txt in enumerate(["Headline", "Quelle", "Sprache", "Datum"]):
hdr[i].text = txt
for p in hdr[i].paragraphs:
p.runs[0].font.bold = True
p.runs[0].font.size = Pt(8)
for art in articles:
row = table.add_row().cells
row[0].text = art.get("headline_de") or art.get("headline") or "Ohne Titel"
row[1].text = art.get("source") or ""
row[2].text = (art.get("language") or "de").upper()
pub = art.get("published_at") or art.get("collected_at") or ""
try:
dt = datetime.fromisoformat(pub.replace("Z", "+00:00"))
row[3].text = dt.strftime("%d.%m.%Y")
except Exception:
row[3].text = pub[:10] if pub else ""
# Schriftgröße reduzieren
for cell in row:
for p in cell.paragraphs:
for run in p.runs:
run.font.size = Pt(8)
# --- Footer ---
doc.add_paragraph()
footer = doc.add_paragraph()
footer.alignment = WD_ALIGN_PARAGRAPH.CENTER
run = footer.add_run(f"Erstellt mit AegisSight Monitor — aegis-sight.de — {now.strftime('%d.%m.%Y')}")
run.font.size = Pt(8)
run.font.color.rgb = RGBColor(0x99, 0x99, 0x99)
buf = io.BytesIO()
doc.save(buf)
return buf.getvalue()

Datei anzeigen

@@ -13,19 +13,11 @@ body { font-family: -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-se
.cover-title { font-size: 26pt; font-weight: 700; color: #0a1832; margin-bottom: 8px; }
.cover-subtitle { font-size: 12pt; color: #666; margin-bottom: 40px; }
.cover-type { font-size: 10pt; color: #888; text-transform: uppercase; letter-spacing: 2px; margin-bottom: 6px; }
.cover-classification { display: inline-block; font-size: 10pt; font-weight: 700; text-transform: uppercase; letter-spacing: 2px; padding: 6px 24px; border: 2px solid; margin: 20px 0; }
.cover-classification.offen { color: #22c55e; border-color: #22c55e; }
.cover-classification.dienstgebrauch { color: #f0b429; border-color: #f0b429; }
.cover-classification.vertraulich { color: #ef4444; border-color: #ef4444; }
.cover-meta { font-size: 9pt; color: #888; margin-top: 40px; }
.cover-meta div { margin-bottom: 3px; }
.cover-brand { font-size: 9pt; color: #aaa; margin-top: 50px; letter-spacing: 1px; }
/* Classification Banner */
.classification-banner { text-align: center; font-size: 8pt; font-weight: 700; text-transform: uppercase; letter-spacing: 2px; padding: 3px 0; margin-bottom: 14px; }
.classification-banner.offen { color: #22c55e; }
.classification-banner.dienstgebrauch { color: #f0b429; }
.classification-banner.vertraulich { color: #ef4444; }
/* Sections */
.section { margin-bottom: 20px; }
@@ -77,8 +69,6 @@ tr:nth-child(even) { background: #f8f9fa; }
<img src="data:image/svg+xml;base64,{{ logo_base64 }}" class="cover-logo" alt="AegisSight">
<div class="cover-type">{{ incident_type_label }}</div>
<div class="cover-title">{{ incident.title }}</div>
<div class="cover-classification {{ classification }}">{{ classification_label }}</div>
<div class="cover-meta">
<div>Stand: {{ report_date }}</div>
<div>Erstellt von: {{ creator }}</div>
@@ -87,8 +77,6 @@ tr:nth-child(even) { background: #f8f9fa; }
<div class="cover-brand">AegisSight Monitor</div>
</div>
<!-- Classification Banner auf jeder Folgeseite -->
<div class="classification-banner {{ classification }}">{{ classification_label }}</div>
<!-- Executive Summary -->
<div class="section">

Datei anzeigen

@@ -0,0 +1,199 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<style>
@page { margin: 20mm 18mm 20mm 18mm; size: A4; @bottom-center { content: "Seite " counter(page) " von " counter(pages); font-size: 8pt; color: #888; } }
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; font-size: 10.5pt; line-height: 1.55; color: #1a1a1a; }
/* Deckblatt */
.cover { page-break-after: always; display: flex; flex-direction: column; justify-content: center; align-items: center; min-height: 85vh; text-align: center; }
.cover-logo { width: 80px; height: auto; margin-bottom: 30px; }
.cover-title { font-size: 26pt; font-weight: 700; color: #0a1832; margin-bottom: 8px; }
.cover-subtitle { font-size: 12pt; color: #666; margin-bottom: 40px; }
.cover-type { font-size: 10pt; color: #888; text-transform: uppercase; letter-spacing: 2px; margin-bottom: 6px; }
.cover-classification { display: inline-block; font-size: 10pt; font-weight: 700; text-transform: uppercase; letter-spacing: 2px; padding: 6px 24px; border: 2px solid; margin: 20px 0; }
.cover-classification.offen { color: #22c55e; border-color: #22c55e; }
.cover-classification.dienstgebrauch { color: #f0b429; border-color: #f0b429; }
.cover-classification.vertraulich { color: #ef4444; border-color: #ef4444; }
.cover-meta { font-size: 9pt; color: #888; margin-top: 40px; }
.cover-meta div { margin-bottom: 3px; }
.cover-brand { font-size: 9pt; color: #aaa; margin-top: 50px; letter-spacing: 1px; }
/* Classification Banner */
.classification-banner { text-align: center; font-size: 8pt; font-weight: 700; text-transform: uppercase; letter-spacing: 2px; padding: 3px 0; margin-bottom: 14px; }
.classification-banner.offen { color: #22c55e; }
.classification-banner.dienstgebrauch { color: #f0b429; }
.classification-banner.vertraulich { color: #ef4444; }
/* Sections */
.section { margin-bottom: 20px; }
.section h2 { font-size: 14pt; font-weight: 700; color: #0a1832; border-bottom: 2px solid #c8a851; padding-bottom: 4px; margin-bottom: 12px; }
.section h3 { font-size: 11pt; font-weight: 600; color: #0a1832; margin: 14px 0 6px; }
/* Executive Summary */
.exec-summary { background: #f8f9fa; border-left: 4px solid #c8a851; padding: 16px 20px; margin-bottom: 20px; }
.exec-summary ul { margin: 8px 0 0 18px; }
.exec-summary li { margin-bottom: 6px; line-height: 1.6; }
/* Lagebild */
.lagebild-content { line-height: 1.7; }
.lagebild-content p { margin-bottom: 8px; }
.lagebild-content strong { font-weight: 600; }
.lagebild-content a { color: #1a5276; text-decoration: underline; }
.lagebild-content ul, .lagebild-content ol { margin: 6px 0 6px 20px; }
.lagebild-content li { margin-bottom: 3px; }
/* Tabellen */
table { width: 100%; border-collapse: collapse; font-size: 9.5pt; margin-bottom: 14px; }
.quellen-table { table-layout: fixed; font-size: 8pt; }
th { background: #0a1832; color: #fff; text-align: left; padding: 6px 10px; font-weight: 600; font-size: 8.5pt; text-transform: uppercase; letter-spacing: 0.5px; }
td { padding: 5px 10px; border-bottom: 1px solid #e0e0e0; }
tr:nth-child(even) { background: #f8f9fa; }
/* Faktencheck */
.fc-badge { display: inline-block; font-size: 7.5pt; font-weight: 700; text-transform: uppercase; letter-spacing: 0.4px; padding: 2px 8px; border-radius: 3px; }
.fc-confirmed { background: #d4edda; color: #155724; }
.fc-disputed { background: #f8d7da; color: #721c24; }
.fc-unconfirmed { background: #fff3cd; color: #856404; }
/* Timeline */
.tl-item { padding: 4px 0; border-left: 2px solid #c8a851; padding-left: 12px; margin-bottom: 6px; }
.tl-date { font-size: 8.5pt; color: #888; }
.tl-title { font-size: 10pt; }
.tl-source { font-size: 8pt; color: #aaa; }
/* Quellenverzeichnis */
.source-ref { font-size: 7pt; color: #666; word-break: break-all; max-width: 350px; overflow: hidden; text-overflow: ellipsis; }
/* Footer */
.report-footer { margin-top: 30px; padding-top: 10px; border-top: 1px solid #ddd; font-size: 8pt; color: #999; text-align: center; }
</style>
</head>
<body>
<!-- Deckblatt -->
<div class="cover">
<img src="data:image/svg+xml;base64,{{ logo_base64 }}" class="cover-logo" alt="AegisSight">
<div class="cover-type">{{ incident_type_label }}</div>
<div class="cover-title">{{ incident.title }}</div>
<div class="cover-classification {{ classification }}">{{ classification_label }}</div>
<div class="cover-meta">
<div>Stand: {{ report_date }}</div>
<div>Erstellt von: {{ creator }}</div>
{% if incident.organization_name %}<div>Organisation: {{ incident.organization_name }}</div>{% endif %}
</div>
<div class="cover-brand">AegisSight Monitor</div>
</div>
<!-- Classification Banner auf jeder Folgeseite -->
<div class="classification-banner {{ classification }}">{{ classification_label }}</div>
<!-- Executive Summary -->
<div class="section">
<h2>Executive Summary</h2>
<div class="exec-summary">
{{ executive_summary | safe }}
</div>
</div>
{% if scope in ('report', 'full') %}
<!-- Faktencheck -->
{% if fact_checks %}
<div class="section">
<h2>Faktencheck</h2>
<table>
<thead><tr><th>Behauptung</th><th>Status</th><th>Quellen</th></tr></thead>
<tbody>
{% for fc in fact_checks %}
<tr>
<td>{{ fc.claim or '' }}</td>
<td><span class="fc-badge fc-{{ fc.status or 'unconfirmed' }}">{{ fc.status_label }}</span></td>
<td>{{ fc.sources_count or 0 }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
<!-- Quellenstatistik -->
{% if source_stats %}
<div class="section">
<h2>Quellenstatistik</h2>
<table>
<thead><tr><th>Quelle</th><th>Artikel</th><th>Sprache</th></tr></thead>
<tbody>
{% for stat in source_stats %}
<tr><td>{{ stat.name }}</td><td>{{ stat.count }}</td><td>{{ stat.languages }}</td></tr>
{% endfor %}
</tbody>
</table>
</div>
<!-- Lagebild -->
<div class="section">
<h2>Lagebild</h2>
{% if lagebild_timestamp %}<p style="font-size:9pt;color:#888;margin-bottom:10px;">Aktualisiert: {{ lagebild_timestamp }}</p>{% endif %}
<div class="lagebild-content">{{ lagebild_html | safe }}</div>
</div>
<!-- Quellenverzeichnis -->
{% if sources %}
<div class="section">
<h2>Quellenverzeichnis</h2>
<table class="quellen-table">
<thead><tr><th style="width:30px">#</th><th style="width:120px">Quelle</th><th>URL</th></tr></thead>
<tbody>
{% for src in sources %}
<tr><td style="font-size:8pt">{{ loop.index }}</td><td style="font-size:8pt">{{ src.name or src.title or '' }}</td><td style="font-size:7pt;color:#666;word-break:break-all;line-height:1.3">{{ src.url or '' }}</td></tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
{% endif %}
{% endif %}
{% if scope == 'full' %}
<!-- Timeline -->
{% if timeline %}
<div class="section" style="page-break-before:always;">
<h2>Ereignis-Timeline</h2>
{% for event in timeline %}
<div class="tl-item">
<div class="tl-date">{{ event.date }}</div>
<div class="tl-title">{{ event.headline }}</div>
<div class="tl-source">{{ event.source }}</div>
</div>
{% endfor %}
</div>
{% endif %}
<!-- Artikelverzeichnis -->
{% if articles %}
<div class="section" style="page-break-before:always;">
<h2>Artikelverzeichnis ({{ articles | length }} Artikel)</h2>
<table>
<thead><tr><th>Headline</th><th>Quelle</th><th>Sprache</th><th>Datum</th></tr></thead>
<tbody>
{% for art in articles %}
<tr>
<td>{{ art.headline_de or art.headline or 'Ohne Titel' }}</td>
<td>{{ art.source or '' }}</td>
<td>{{ (art.language or 'de') | upper }}</td>
<td>{{ art.pub_date }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
{% endif %}
<div class="report-footer">
Erstellt mit AegisSight Monitor &mdash; aegis-sight.de &mdash; {{ report_date }}
</div>
</body>
</html>

Datei anzeigen

@@ -635,7 +635,6 @@ async def export_incident(
incident_id: int,
format: str = Query("pdf", pattern="^(pdf|docx)$"),
scope: str = Query("report", pattern="^(summary|report|full)$"),
classification: str = Query("offen", pattern="^(offen|dienstgebrauch|vertraulich)$"),
current_user: dict = Depends(get_current_user),
db: aiosqlite.Connection = Depends(db_dependency),
):
@@ -690,7 +689,7 @@ async def export_incident(
scope_labels = {"summary": "executive_summary", "report": "lagebericht", "full": "vollstaendig"}
if format == "pdf":
pdf_bytes = await generate_pdf(incident, articles, fact_checks, snapshots, scope, classification, creator, exec_summary)
pdf_bytes = await generate_pdf(incident, articles, fact_checks, snapshots, scope, creator, exec_summary)
filename = f"{slug}_{scope_labels[scope]}_{date_str}.pdf"
return StreamingResponse(
io.BytesIO(pdf_bytes),
@@ -698,7 +697,7 @@ async def export_incident(
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
else:
docx_bytes = await generate_docx(incident, articles, fact_checks, snapshots, scope, classification, creator, exec_summary)
docx_bytes = await generate_docx(incident, articles, fact_checks, snapshots, scope, creator, exec_summary)
filename = f"{slug}_{scope_labels[scope]}_{date_str}.docx"
return StreamingResponse(
io.BytesIO(docx_bytes),

708
src/routers/incidents.py.bak Normale Datei
Datei anzeigen

@@ -0,0 +1,708 @@
"""Incidents-Router: Lagen verwalten (Multi-Tenant)."""
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, status
from fastapi.responses import StreamingResponse
from models import IncidentCreate, IncidentUpdate, IncidentResponse, SubscriptionUpdate, SubscriptionResponse
from auth import get_current_user
from middleware.license_check import require_writable_license
from database import db_dependency, get_db
from datetime import datetime
from config import TIMEZONE
import asyncio
import aiosqlite
import io
import json
import logging
import re
import unicodedata
_geoparse_logger = logging.getLogger("osint.geoparse_bg")
router = APIRouter(prefix="/api/incidents", tags=["incidents"])
INCIDENT_UPDATE_COLUMNS = {
"title", "description", "type", "status", "refresh_mode",
"refresh_interval", "retention_days", "international_sources", "include_telegram", "visibility",
}
async def _check_incident_access(
db: aiosqlite.Connection, incident_id: int, user_id: int, tenant_id: int
) -> aiosqlite.Row:
"""Lage laden und Zugriff pruefen (Tenant + Sichtbarkeit)."""
cursor = await db.execute(
"SELECT * FROM incidents WHERE id = ? AND tenant_id = ?",
(incident_id, tenant_id),
)
row = await cursor.fetchone()
if not row:
raise HTTPException(status_code=404, detail="Lage nicht gefunden")
if row["visibility"] == "private" and row["created_by"] != user_id:
raise HTTPException(status_code=403, detail="Kein Zugriff auf private Lage")
return row
async def _enrich_incident(db: aiosqlite.Connection, row: aiosqlite.Row) -> dict:
"""Incident-Row mit Statistiken und Ersteller-Name anreichern."""
incident = dict(row)
cursor = await db.execute(
"SELECT COUNT(*) as cnt FROM articles WHERE incident_id = ?",
(incident["id"],),
)
article_count = (await cursor.fetchone())["cnt"]
cursor = await db.execute(
"SELECT COUNT(DISTINCT source) as cnt FROM articles WHERE incident_id = ?",
(incident["id"],),
)
source_count = (await cursor.fetchone())["cnt"]
cursor = await db.execute(
"SELECT email FROM users WHERE id = ?",
(incident["created_by"],),
)
user_row = await cursor.fetchone()
incident["article_count"] = article_count
incident["source_count"] = source_count
incident["created_by_username"] = user_row["email"] if user_row else "Unbekannt"
return incident
@router.get("", response_model=list[IncidentResponse])
async def list_incidents(
status_filter: str = None,
current_user: dict = Depends(get_current_user),
db: aiosqlite.Connection = Depends(db_dependency),
):
"""Alle Lagen des Tenants auflisten (oeffentliche + eigene private)."""
tenant_id = current_user.get("tenant_id")
user_id = current_user["id"]
query = "SELECT * FROM incidents WHERE tenant_id = ? AND (visibility = 'public' OR created_by = ?)"
params = [tenant_id, user_id]
if status_filter:
query += " AND status = ?"
params.append(status_filter)
query += " ORDER BY updated_at DESC"
cursor = await db.execute(query, params)
rows = await cursor.fetchall()
results = []
for row in rows:
results.append(await _enrich_incident(db, row))
return results
@router.post("", response_model=IncidentResponse, status_code=status.HTTP_201_CREATED)
async def create_incident(
data: IncidentCreate,
current_user: dict = Depends(require_writable_license),
db: aiosqlite.Connection = Depends(db_dependency),
):
"""Neue Lage anlegen."""
tenant_id = current_user.get("tenant_id")
now = datetime.now(TIMEZONE).strftime('%Y-%m-%d %H:%M:%S')
cursor = await db.execute(
"""INSERT INTO incidents (title, description, type, refresh_mode, refresh_interval,
retention_days, international_sources, include_telegram, visibility,
tenant_id, created_by, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(
data.title,
data.description,
data.type,
data.refresh_mode,
data.refresh_interval,
data.retention_days,
1 if data.international_sources else 0,
1 if data.include_telegram else 0,
data.visibility,
tenant_id,
current_user["id"],
now,
now,
),
)
await db.commit()
cursor = await db.execute("SELECT * FROM incidents WHERE id = ?", (cursor.lastrowid,))
row = await cursor.fetchone()
return await _enrich_incident(db, row)
@router.get("/refreshing")
async def get_refreshing_incidents(
current_user: dict = Depends(get_current_user),
db: aiosqlite.Connection = Depends(db_dependency),
):
"""Gibt IDs aller Lagen mit laufendem Refresh zurueck (nur eigener Tenant)."""
tenant_id = current_user.get("tenant_id")
cursor = await db.execute(
"""SELECT rl.incident_id, rl.started_at FROM refresh_log rl
JOIN incidents i ON i.id = rl.incident_id
WHERE rl.status = 'running'
AND i.tenant_id = ?
AND (i.visibility = 'public' OR i.created_by = ?)""",
(tenant_id, current_user["id"]),
)
rows = await cursor.fetchall()
return {
"refreshing": [row["incident_id"] for row in rows],
"details": {str(row["incident_id"]): {"started_at": row["started_at"]} for row in rows},
}
@router.get("/{incident_id}", response_model=IncidentResponse)
async def get_incident(
incident_id: int,
current_user: dict = Depends(get_current_user),
db: aiosqlite.Connection = Depends(db_dependency),
):
"""Einzelne Lage abrufen."""
tenant_id = current_user.get("tenant_id")
row = await _check_incident_access(db, incident_id, current_user["id"], tenant_id)
return await _enrich_incident(db, row)
@router.put("/{incident_id}", response_model=IncidentResponse)
async def update_incident(
incident_id: int,
data: IncidentUpdate,
current_user: dict = Depends(get_current_user),
db: aiosqlite.Connection = Depends(db_dependency),
):
"""Lage aktualisieren."""
tenant_id = current_user.get("tenant_id")
row = await _check_incident_access(db, incident_id, current_user["id"], tenant_id)
updates = {}
for field, value in data.model_dump(exclude_none=True).items():
if field not in INCIDENT_UPDATE_COLUMNS:
continue
if field in ("international_sources", "include_telegram"):
updates[field] = 1 if value else 0
else:
updates[field] = value
if not updates:
return await _enrich_incident(db, row)
updates["updated_at"] = datetime.now(TIMEZONE).strftime('%Y-%m-%d %H:%M:%S')
set_clause = ", ".join(f"{k} = ?" for k in updates)
values = list(updates.values()) + [incident_id]
await db.execute(f"UPDATE incidents SET {set_clause} WHERE id = ?", values)
await db.commit()
cursor = await db.execute("SELECT * FROM incidents WHERE id = ?", (incident_id,))
row = await cursor.fetchone()
return await _enrich_incident(db, row)
@router.delete("/{incident_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_incident(
incident_id: int,
current_user: dict = Depends(get_current_user),
db: aiosqlite.Connection = Depends(db_dependency),
):
"""Lage loeschen (nur Ersteller)."""
tenant_id = current_user.get("tenant_id")
cursor = await db.execute(
"SELECT id, created_by FROM incidents WHERE id = ? AND tenant_id = ?",
(incident_id, tenant_id),
)
incident = await cursor.fetchone()
if not incident:
raise HTTPException(status_code=404, detail="Lage nicht gefunden")
if incident["created_by"] != current_user["id"]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Nur der Ersteller kann diese Lage loeschen",
)
try:
await db.execute("DELETE FROM incidents WHERE id = ?", (incident_id,))
await db.commit()
except Exception as e:
if "database is locked" in str(e):
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Datenbank ist momentan beschaeftigt. Bitte in wenigen Sekunden erneut versuchen.",
)
raise
@router.get("/{incident_id}/articles")
async def get_articles(
incident_id: int,
current_user: dict = Depends(get_current_user),
db: aiosqlite.Connection = Depends(db_dependency),
):
"""Alle Artikel einer Lage abrufen."""
tenant_id = current_user.get("tenant_id")
await _check_incident_access(db, incident_id, current_user["id"], tenant_id)
cursor = await db.execute(
"SELECT * FROM articles WHERE incident_id = ? ORDER BY collected_at DESC",
(incident_id,),
)
rows = await cursor.fetchall()
return [dict(row) for row in rows]
@router.get("/{incident_id}/snapshots")
async def get_snapshots(
incident_id: int,
current_user: dict = Depends(get_current_user),
db: aiosqlite.Connection = Depends(db_dependency),
):
"""Lageberichte (Snapshots) einer Lage abrufen."""
tenant_id = current_user.get("tenant_id")
await _check_incident_access(db, incident_id, current_user["id"], tenant_id)
cursor = await db.execute(
"""SELECT id, incident_id, summary, sources_json,
article_count, fact_check_count, created_at
FROM incident_snapshots WHERE incident_id = ?
ORDER BY created_at DESC""",
(incident_id,),
)
rows = await cursor.fetchall()
return [dict(row) for row in rows]
@router.get("/{incident_id}/factchecks")
async def get_factchecks(
incident_id: int,
current_user: dict = Depends(get_current_user),
db: aiosqlite.Connection = Depends(db_dependency),
):
"""Faktenchecks einer Lage abrufen."""
tenant_id = current_user.get("tenant_id")
await _check_incident_access(db, incident_id, current_user["id"], tenant_id)
cursor = await db.execute(
"SELECT * FROM fact_checks WHERE incident_id = ? ORDER BY checked_at DESC",
(incident_id,),
)
rows = await cursor.fetchall()
return [dict(row) for row in rows]
@router.get("/{incident_id}/locations")
async def get_locations(
incident_id: int,
current_user: dict = Depends(get_current_user),
db: aiosqlite.Connection = Depends(db_dependency),
):
"""Geografische Orte einer Lage abrufen (aggregiert nach Ort)."""
tenant_id = current_user.get("tenant_id")
await _check_incident_access(db, incident_id, current_user["id"], tenant_id)
cursor = await db.execute(
"""SELECT al.location_name, al.location_name_normalized, al.country_code,
al.latitude, al.longitude, al.confidence, al.category,
a.id as article_id, a.headline, a.headline_de, a.source, a.source_url
FROM article_locations al
JOIN articles a ON a.id = al.article_id
WHERE al.incident_id = ?
ORDER BY al.location_name_normalized, a.collected_at DESC""",
(incident_id,),
)
rows = await cursor.fetchall()
# Aggregierung nach normalisiertem Ortsnamen + Koordinaten
loc_map = {}
for row in rows:
row = dict(row)
key = (row["location_name_normalized"] or row["location_name"], round(row["latitude"], 2), round(row["longitude"], 2))
if key not in loc_map:
loc_map[key] = {
"location_name": row["location_name_normalized"] or row["location_name"],
"lat": row["latitude"],
"lon": row["longitude"],
"country_code": row["country_code"],
"confidence": row["confidence"],
"article_count": 0,
"articles": [],
"categories": {},
}
loc_map[key]["article_count"] += 1
cat = row["category"] or "mentioned"
loc_map[key]["categories"][cat] = loc_map[key]["categories"].get(cat, 0) + 1
# Maximal 10 Artikel pro Ort mitliefern
if len(loc_map[key]["articles"]) < 10:
loc_map[key]["articles"].append({
"id": row["article_id"],
"headline": row["headline_de"] or row["headline"],
"source": row["source"],
"source_url": row["source_url"],
})
# Dominanteste Kategorie pro Ort bestimmen (Prioritaet: primary > secondary > tertiary > mentioned)
priority = {"primary": 4, "secondary": 3, "tertiary": 2, "mentioned": 1}
result = []
for loc in loc_map.values():
cats = loc.pop("categories")
if cats:
best_cat = max(cats, key=lambda c: (priority.get(c, 0), cats[c]))
else:
best_cat = "mentioned"
loc["category"] = best_cat
result.append(loc)
# Category-Labels aus Incident laden
cursor = await db.execute(
"SELECT category_labels FROM incidents WHERE id = ?", (incident_id,)
)
inc_row = await cursor.fetchone()
category_labels = None
if inc_row and inc_row["category_labels"]:
try:
category_labels = json.loads(inc_row["category_labels"])
except (json.JSONDecodeError, TypeError):
pass
return {"category_labels": category_labels, "locations": result}
# Geoparse-Status pro Incident (in-memory)
_geoparse_status: dict[int, dict] = {}
async def _run_geoparse_background(incident_id: int, tenant_id: int | None):
"""Hintergrund-Task: Geoparsing fuer alle Artikel einer Lage."""
_geoparse_status[incident_id] = {"status": "running", "processed": 0, "total": 0, "locations": 0}
db = None
try:
from agents.geoparsing import geoparse_articles
db = await get_db()
# Incident-Kontext fuer Haiku laden
cursor = await db.execute(
"SELECT title, description FROM incidents WHERE id = ?", (incident_id,)
)
inc_row = await cursor.fetchone()
incident_context = ""
if inc_row:
incident_context = f"{inc_row['title']} - {inc_row['description'] or ''}"
cursor = await db.execute(
"""SELECT a.* FROM articles a
WHERE a.incident_id = ?
AND a.id NOT IN (SELECT DISTINCT article_id FROM article_locations WHERE incident_id = ?)""",
(incident_id, incident_id),
)
articles = [dict(row) for row in await cursor.fetchall()]
if not articles:
_geoparse_status[incident_id] = {"status": "done", "processed": 0, "total": 0, "locations": 0}
return
total = len(articles)
_geoparse_status[incident_id]["total"] = total
_geoparse_logger.info(f"Geoparsing Hintergrund: {total} Artikel fuer Lage {incident_id}")
# In Batches verarbeiten (50 Artikel pro Batch)
batch_size = 50
geo_count = 0
processed = 0
for i in range(0, total, batch_size):
batch = articles[i:i + batch_size]
geo_result = await geoparse_articles(batch, incident_context)
# Tuple-Rückgabe: (locations_dict, category_labels)
if isinstance(geo_result, tuple):
batch_geo_results, batch_labels = geo_result
# Labels beim ersten Batch speichern
if batch_labels and i == 0:
try:
await db.execute(
"UPDATE incidents SET category_labels = ? WHERE id = ? AND category_labels IS NULL",
(json.dumps(batch_labels, ensure_ascii=False), incident_id),
)
await db.commit()
except Exception:
pass
else:
batch_geo_results = geo_result
for art_id, locations in batch_geo_results.items():
for loc in locations:
await db.execute(
"""INSERT INTO article_locations
(article_id, incident_id, location_name, location_name_normalized,
country_code, latitude, longitude, confidence, source_text, tenant_id, category)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(art_id, incident_id, loc["location_name"], loc["location_name_normalized"],
loc["country_code"], loc["lat"], loc["lon"], loc["confidence"],
loc.get("source_text", ""), tenant_id, loc.get("category", "mentioned")),
)
geo_count += 1
await db.commit()
processed += len(batch)
_geoparse_status[incident_id] = {"status": "running", "processed": processed, "total": total, "locations": geo_count}
_geoparse_status[incident_id] = {"status": "done", "processed": processed, "total": total, "locations": geo_count}
_geoparse_logger.info(f"Geoparsing fertig: {geo_count} Orte aus {processed} Artikeln (Lage {incident_id})")
except Exception as e:
_geoparse_status[incident_id] = {"status": "error", "error": str(e)}
_geoparse_logger.error(f"Geoparsing Fehler (Lage {incident_id}): {e}")
finally:
if db:
await db.close()
@router.post("/{incident_id}/geoparse")
async def trigger_geoparse(
incident_id: int,
current_user: dict = Depends(get_current_user),
db: aiosqlite.Connection = Depends(db_dependency),
):
"""Geoparsing fuer alle Artikel einer Lage als Hintergrund-Task starten."""
tenant_id = current_user.get("tenant_id")
await _check_incident_access(db, incident_id, current_user["id"], tenant_id)
# Bereits laufend?
existing = _geoparse_status.get(incident_id, {})
if existing.get("status") == "running":
return {"status": "running", "message": f"Läuft bereits ({existing.get('processed', 0)}/{existing.get('total', 0)} Artikel)"}
# Pruefen ob es ueberhaupt ungeparsete Artikel gibt
cursor = await db.execute(
"""SELECT COUNT(*) as cnt FROM articles a
WHERE a.incident_id = ?
AND a.id NOT IN (SELECT DISTINCT article_id FROM article_locations WHERE incident_id = ?)""",
(incident_id, incident_id),
)
count = (await cursor.fetchone())["cnt"]
if count == 0:
return {"status": "done", "message": "Alle Artikel wurden bereits verarbeitet", "locations": 0}
# Hintergrund-Task starten
asyncio.create_task(_run_geoparse_background(incident_id, tenant_id))
return {"status": "started", "message": f"Geoparsing gestartet für {count} Artikel"}
@router.get("/{incident_id}/geoparse-status")
async def get_geoparse_status(
incident_id: int,
current_user: dict = Depends(get_current_user),
db: aiosqlite.Connection = Depends(db_dependency),
):
"""Status des laufenden Geoparsing-Tasks abfragen."""
tenant_id = current_user.get("tenant_id")
await _check_incident_access(db, incident_id, current_user["id"], tenant_id)
return _geoparse_status.get(incident_id, {"status": "idle"})
@router.get("/{incident_id}/refresh-log")
async def get_refresh_log(
incident_id: int,
limit: int = 20,
current_user: dict = Depends(get_current_user),
db: aiosqlite.Connection = Depends(db_dependency),
):
"""Refresh-Verlauf einer Lage abrufen."""
tenant_id = current_user.get("tenant_id")
await _check_incident_access(db, incident_id, current_user["id"], tenant_id)
cursor = await db.execute(
"""SELECT id, started_at, completed_at, articles_found, status,
trigger_type, retry_count, error_message
FROM refresh_log WHERE incident_id = ?
ORDER BY started_at DESC LIMIT ?""",
(incident_id, min(limit, 100)),
)
rows = await cursor.fetchall()
results = []
for row in rows:
entry = dict(row)
if entry["started_at"] and entry["completed_at"]:
try:
start = datetime.fromisoformat(entry["started_at"])
end = datetime.fromisoformat(entry["completed_at"])
entry["duration_seconds"] = round((end - start).total_seconds(), 1)
except Exception:
entry["duration_seconds"] = None
else:
entry["duration_seconds"] = None
results.append(entry)
return results
@router.get("/{incident_id}/subscription", response_model=SubscriptionResponse)
async def get_subscription(
incident_id: int,
current_user: dict = Depends(get_current_user),
db: aiosqlite.Connection = Depends(db_dependency),
):
"""E-Mail-Abo-Einstellungen des aktuellen Nutzers fuer eine Lage abrufen."""
tenant_id = current_user.get("tenant_id")
await _check_incident_access(db, incident_id, current_user["id"], tenant_id)
cursor = await db.execute(
"""SELECT notify_email_summary, notify_email_new_articles, notify_email_status_change
FROM incident_subscriptions WHERE user_id = ? AND incident_id = ?""",
(current_user["id"], incident_id),
)
row = await cursor.fetchone()
if row:
return dict(row)
return {"notify_email_summary": False, "notify_email_new_articles": False, "notify_email_status_change": False}
@router.put("/{incident_id}/subscription", response_model=SubscriptionResponse)
async def update_subscription(
incident_id: int,
data: SubscriptionUpdate,
current_user: dict = Depends(get_current_user),
db: aiosqlite.Connection = Depends(db_dependency),
):
"""E-Mail-Abo-Einstellungen des aktuellen Nutzers fuer eine Lage setzen."""
tenant_id = current_user.get("tenant_id")
await _check_incident_access(db, incident_id, current_user["id"], tenant_id)
await db.execute(
"""INSERT INTO incident_subscriptions (user_id, incident_id, notify_email_summary, notify_email_new_articles, notify_email_status_change)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(user_id, incident_id) DO UPDATE SET
notify_email_summary = excluded.notify_email_summary,
notify_email_new_articles = excluded.notify_email_new_articles,
notify_email_status_change = excluded.notify_email_status_change""",
(
current_user["id"],
incident_id,
1 if data.notify_email_summary else 0,
1 if data.notify_email_new_articles else 0,
1 if data.notify_email_status_change else 0,
),
)
await db.commit()
return {
"notify_email_summary": data.notify_email_summary,
"notify_email_new_articles": data.notify_email_new_articles,
"notify_email_status_change": data.notify_email_status_change,
}
@router.post("/{incident_id}/refresh")
async def trigger_refresh(
incident_id: int,
current_user: dict = Depends(require_writable_license),
db: aiosqlite.Connection = Depends(db_dependency),
):
"""Manuellen Refresh fuer eine Lage ausloesen."""
tenant_id = current_user.get("tenant_id")
await _check_incident_access(db, incident_id, current_user["id"], tenant_id)
from agents.orchestrator import orchestrator
enqueued = await orchestrator.enqueue_refresh(incident_id, user_id=current_user["id"])
if not enqueued:
return {"status": "skipped", "incident_id": incident_id}
return {"status": "queued", "incident_id": incident_id}
@router.post("/{incident_id}/cancel-refresh")
async def cancel_refresh(
incident_id: int,
current_user: dict = Depends(get_current_user),
db: aiosqlite.Connection = Depends(db_dependency),
):
"""Laufenden Refresh fuer eine Lage abbrechen."""
tenant_id = current_user.get("tenant_id")
await _check_incident_access(db, incident_id, current_user["id"], tenant_id)
from agents.orchestrator import orchestrator
cancelled = await orchestrator.cancel_refresh(incident_id)
return {"status": "cancelling" if cancelled else "not_running"}
def _slugify(text: str) -> str:
"""Dateinamen-sicherer Slug aus Titel."""
replacements = {
"\u00e4": "ae", "\u00f6": "oe", "\u00fc": "ue", "\u00df": "ss",
"\u00c4": "Ae", "\u00d6": "Oe", "\u00dc": "Ue",
}
for src, dst in replacements.items():
text = text.replace(src, dst)
text = unicodedata.normalize("NFKD", text)
text = re.sub(r"[^\w\s-]", "", text)
text = re.sub(r"[\s_]+", "-", text).strip("-")
return text[:80].lower()
@router.get("/{incident_id}/export")
async def export_incident(
incident_id: int,
format: str = Query("pdf", pattern="^(pdf|docx)$"),
scope: str = Query("report", pattern="^(summary|report|full)$"),
classification: str = Query("offen", pattern="^(offen|dienstgebrauch|vertraulich)$"),
current_user: dict = Depends(get_current_user),
db: aiosqlite.Connection = Depends(db_dependency),
):
"""Lage als PDF oder Word exportieren."""
from report_generator import generate_pdf, generate_docx, generate_executive_summary
tenant_id = current_user.get("tenant_id")
row = await _check_incident_access(db, incident_id, current_user["id"], tenant_id)
incident = dict(row)
# Ersteller-Name
cursor = await db.execute("SELECT email FROM users WHERE id = ?", (incident["created_by"],))
user_row = await cursor.fetchone()
creator = user_row["email"] if user_row else "Unbekannt"
# Artikel
cursor = await db.execute(
"SELECT * FROM articles WHERE incident_id = ? ORDER BY collected_at DESC",
(incident_id,),
)
articles = [dict(r) for r in await cursor.fetchall()]
# Faktenchecks
cursor = await db.execute(
"SELECT * FROM fact_checks WHERE incident_id = ? ORDER BY checked_at DESC",
(incident_id,),
)
fact_checks = [dict(r) for r in await cursor.fetchall()]
# Snapshots (nur bei full)
snapshots = []
if scope == "full":
cursor = await db.execute(
"SELECT * FROM incident_snapshots WHERE incident_id = ? ORDER BY created_at DESC",
(incident_id,),
)
snapshots = [dict(r) for r in await cursor.fetchall()]
# Executive Summary (KI-generiert, gecacht)
exec_summary = incident.get("executive_summary")
if not exec_summary:
summary_text = incident.get("summary") or ""
exec_summary = await generate_executive_summary(summary_text)
await db.execute(
"UPDATE incidents SET executive_summary = ? WHERE id = ?",
(exec_summary, incident_id),
)
await db.commit()
date_str = datetime.now(TIMEZONE).strftime("%Y%m%d")
slug = _slugify(incident["title"])
scope_labels = {"summary": "executive_summary", "report": "lagebericht", "full": "vollstaendig"}
if format == "pdf":
pdf_bytes = await generate_pdf(incident, articles, fact_checks, snapshots, scope, classification, creator, exec_summary)
filename = f"{slug}_{scope_labels[scope]}_{date_str}.pdf"
return StreamingResponse(
io.BytesIO(pdf_bytes),
media_type="application/pdf",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
else:
docx_bytes = await generate_docx(incident, articles, fact_checks, snapshots, scope, classification, creator, exec_summary)
filename = f"{slug}_{scope_labels[scope]}_{date_str}.docx"
return StreamingResponse(
io.BytesIO(docx_bytes),
media_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)

Datei anzeigen

@@ -680,14 +680,6 @@
<label class="export-radio"><input type="radio" name="export-format" value="pdf" checked><span>PDF</span></label>
<label class="export-radio"><input type="radio" name="export-format" value="docx"><span>Word (DOCX)</span></label>
</div>
<div style="margin-bottom:8px;">
<label style="font-size:11px;text-transform:uppercase;letter-spacing:1px;color:var(--text-secondary);display:block;margin-bottom:8px;">Klassifizierung</label>
<select id="export-classification" class="form-control" style="width:100%;padding:8px 12px;background:var(--bg-secondary);border:1px solid var(--border);border-radius:var(--radius-sm);color:var(--text-primary);font-size:13px;">
<option value="offen">Offen</option>
<option value="dienstgebrauch">Nur f&uuml;r den Dienstgebrauch</option>
<option value="vertraulich" selected>Vertraulich</option>
</select>
</div>
</div>
<div class="modal-footer" style="padding:12px 20px;display:flex;justify-content:flex-end;gap:8px;border-top:1px solid var(--border);">
<button class="btn btn-secondary" onclick="closeModal('modal-export')">Abbrechen</button>

700
src/static/dashboard.html.bak Normale Datei
Datei anzeigen

@@ -0,0 +1,700 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<script>(function(){var t=localStorage.getItem('osint_theme');if(t)document.documentElement.setAttribute('data-theme',t);try{var a=JSON.parse(localStorage.getItem('osint_a11y')||'{}');Object.keys(a).forEach(function(k){if(a[k])document.documentElement.setAttribute('data-a11y-'+k,'true');});}catch(e){}})()</script>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
<link rel="apple-touch-icon" href="/static/favicon.svg">
<title>AegisSight Monitor</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Poppins:wght@400;500;600;700&display=swap" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/gridstack@12/dist/gridstack.min.css" rel="stylesheet">
<link rel="stylesheet" href="/static/vendor/leaflet.css">
<link rel="stylesheet" href="/static/vendor/MarkerCluster.css">
<link rel="stylesheet" href="/static/vendor/MarkerCluster.Default.css">
<link rel="stylesheet" href="/static/css/style.css?v=20260316k">
<style>
/* 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:hover { background:var(--bg-secondary); }
.export-radio input[type="radio"] { accent-color:var(--accent); width:16px; height:16px; cursor:pointer; flex-shrink:0; }
.export-radio input[type="radio"]:checked ~ span:first-of-type { color:var(--accent); font-weight:600; }
.export-radio span:first-of-type { font-size:13px; }
.export-radio-desc { font-size:11px; color:var(--text-tertiary); margin-left:auto; }
</style>
</head>
<body>
<a href="#main-content" class="skip-link">Zum Hauptinhalt springen</a>
<div class="dashboard">
<!-- Header -->
<header class="header">
<div class="header-left">
<div class="header-logo">Aegis<span>Sight</span> Monitor</div>
<h1 class="sr-only">AegisSight Monitor Dashboard</h1>
</div>
<div class="header-right">
<div class="theme-switch" id="theme-toggle" onclick="ThemeManager.toggle()" role="switch" aria-checked="true" aria-label="Dark Mode" title="Theme wechseln">
<span class="theme-switch-icon theme-switch-sun">☀︎</span>
<div class="theme-switch-track">
<div class="theme-switch-knob"></div>
</div>
<span class="theme-switch-icon theme-switch-moon"></span>
</div>
<div class="header-user-info">
<button class="header-user-btn" id="header-user-btn" aria-expanded="false" aria-haspopup="true">
<span class="header-user" id="header-user"></span>
<span class="header-user-chevron" aria-hidden="true">&#9662;</span>
</button>
<div class="header-user-dropdown" id="header-user-dropdown" role="menu">
<div class="header-dropdown-row">
<span class="header-dropdown-label">Organisation</span>
<span class="header-dropdown-value" id="header-org-name">-</span>
</div>
<div class="header-dropdown-row">
<span class="header-dropdown-label">Lizenz</span>
<span class="header-dropdown-value" id="header-license-info">-</span>
</div>
<div id="credits-section" class="credits-section" style="display: none;">
<div class="credits-divider"></div>
<div class="credits-label">Credits</div>
<div class="credits-bar-container">
<div id="credits-bar" class="credits-bar"></div>
</div>
<div class="credits-info">
<span><span id="credits-remaining">0</span> von <span id="credits-total">0</span></span>
<span class="credits-percent" id="credits-percent"></span>
</div>
</div>
</div>
</div>
<div class="header-license-warning" id="header-license-warning"></div>
<button class="btn btn-secondary btn-small" id="logout-btn">Abmelden</button>
</div>
</header>
<!-- Sidebar -->
<nav class="sidebar" aria-label="Seitenleiste">
<div class="sidebar-section">
<button class="btn btn-primary btn-full btn-small" id="new-incident-btn" style="margin-bottom:6px;">+ Neuer Fall</button>
</div>
<div class="sidebar-filter">
<button class="sidebar-filter-btn active" data-filter="all" onclick="App.setSidebarFilter('all')" aria-pressed="true">Alle</button>
<button class="sidebar-filter-btn" data-filter="mine" onclick="App.setSidebarFilter('mine')" aria-pressed="false">Eigene</button>
</div>
<div class="sidebar-section">
<h2 class="sidebar-section-title collapsible" onclick="App.toggleSidebarSection('active-incidents')" role="button" tabindex="0" aria-expanded="true">
<span class="sidebar-chevron" id="chevron-active-incidents" aria-hidden="true">&#9662;</span>
Live-Monitoring
<span class="sidebar-section-count" id="count-active-incidents"></span>
</h2>
<div id="active-incidents" aria-live="polite"></div>
</div>
<div class="sidebar-section">
<h2 class="sidebar-section-title collapsible" onclick="App.toggleSidebarSection('active-research')" role="button" tabindex="0" aria-expanded="true">
<span class="sidebar-chevron" id="chevron-active-research" aria-hidden="true">&#9662;</span>
Recherchen
<span class="sidebar-section-count" id="count-active-research"></span>
</h2>
<div id="active-research" aria-live="polite"></div>
</div>
<div class="sidebar-section">
<h2 class="sidebar-section-title collapsible" onclick="App.toggleSidebarSection('archived-incidents')" role="button" tabindex="0" aria-expanded="false">
<span class="sidebar-chevron" id="chevron-archived-incidents" aria-hidden="true">&#9662;</span>
Archiv
<span class="sidebar-section-count" id="count-archived-incidents"></span>
</h2>
<div id="archived-incidents" aria-live="polite" style="display:none;"></div>
</div>
<div class="sidebar-sources-link">
<button class="btn btn-secondary btn-full btn-small" onclick="App.openSourceManagement()">Quellen verwalten</button>
<button class="btn btn-secondary btn-full btn-small sidebar-feedback-btn" onclick="App.openFeedback()">Feedback senden</button>
<button class="btn btn-secondary btn-full btn-small" onclick="Tutorial.start()" title="Interaktiven Rundgang starten">Rundgang starten</button>
<div class="sidebar-stats-mini">
<span id="stat-sources-count">0 Quellen</span> &middot; <span id="stat-articles-count">0 Artikel</span>
</div>
</div>
</nav>
<!-- Main Content -->
<main class="main-content" id="main-content">
<div class="empty-state" id="empty-state">
<div class="empty-state-icon">&#9737;</div>
<div class="empty-state-title">Kein Vorfall ausgewählt</div>
<div class="empty-state-text">Erstelle einen neuen Fall oder wähle einen bestehenden aus der Seitenleiste.</div>
</div>
<!-- Netzwerkanalyse View (hidden by default) -->
<!-- Lagebild (hidden by default) -->
<div id="incident-view" style="display:none;">
<!-- Header Strip -->
<div class="incident-header-strip" id="incident-header-strip">
<div class="incident-header-row0">
<span class="incident-type-badge" id="incident-type-badge"></span>
<span class="auto-refresh-indicator" id="meta-refresh-mode"></span>
</div>
<div class="incident-header-row1">
<div class="incident-header-left">
<h2 class="incident-header-title" id="incident-title"></h2>
</div>
<div class="incident-header-actions">
<button class="btn btn-primary btn-small" id="refresh-btn">Aktualisieren</button>
<button class="btn btn-secondary btn-small" id="edit-incident-btn">Bearbeiten</button>
<button class="btn btn-secondary btn-small" onclick="App.openExportModal()">Bericht exportieren</button>
<button class="btn btn-secondary btn-small" id="archive-incident-btn">Archivieren</button>
<button class="btn btn-danger btn-small" id="delete-incident-btn">Löschen</button>
</div>
</div>
<div class="incident-header-row2">
<div class="incident-header-row2-left">
<span class="incident-creator-badge">von <strong id="incident-creator"></strong></span>
<span class="intl-badge" id="intl-badge"></span>
<span id="incident-description" class="incident-description-text"></span>
</div>
<div class="incident-header-row2-right">
<div class="summary-meta" id="summary-meta">
<span id="meta-updated" class="meta-updated-link" role="button" tabindex="0" onclick="App.toggleRefreshHistory()" onkeydown="if(event.key==='Enter')App.toggleRefreshHistory()"></span>
</div>
<div class="refresh-history-popover" id="refresh-history-popover" style="display:none;">
<div class="refresh-history-header">
<span class="refresh-history-title">Refresh-Verlauf</span>
<button class="refresh-history-close" onclick="App.closeRefreshHistory()">&times;</button>
</div>
<div class="refresh-history-list" id="refresh-history-list">
<div style="padding:12px;color:var(--text-disabled);font-size:12px;">Lade...</div>
</div>
</div>
</div>
</div>
</div>
<!-- Fortschrittsanzeige -->
<div class="progress-bar" id="progress-bar" role="progressbar" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100" aria-label="Verarbeitungsfortschritt" style="display:none;">
<div class="progress-steps">
<div class="progress-step" id="step-researching">
<div class="progress-step-dot"></div>
<span>Recherche</span>
</div>
<div class="progress-step" id="step-analyzing">
<div class="progress-step-dot"></div>
<span>Analyse</span>
</div>
<div class="progress-step" id="step-factchecking">
<div class="progress-step-dot"></div>
<span>Faktencheck</span>
</div>
</div>
<div class="progress-track">
<div class="progress-fill" id="progress-fill"></div>
</div>
<div class="progress-label-container">
<span id="progress-label" class="progress-label">Warte auf Start...</span>
<span id="progress-timer" class="progress-timer"></span>
</div>
<button id="progress-cancel-btn" class="progress-cancel-btn" onclick="App.cancelRefresh()">Abbrechen</button>
</div>
<!-- Layout-Toolbar -->
<div class="layout-toolbar" id="layout-toolbar" style="display:none;">
<div class="layout-toggles">
<button class="layout-toggle-btn active" data-tile="lagebild" onclick="LayoutManager.toggleTile('lagebild')" aria-pressed="true">Lagebild</button>
<button class="layout-toggle-btn active" data-tile="faktencheck" onclick="LayoutManager.toggleTile('faktencheck')" aria-pressed="true">Faktencheck</button>
<button class="layout-toggle-btn active" data-tile="quellen" onclick="LayoutManager.toggleTile('quellen')" aria-pressed="true">Quellen</button>
<button class="layout-toggle-btn active" data-tile="timeline" onclick="LayoutManager.toggleTile('timeline')" aria-pressed="true">Timeline</button>
<button class="layout-toggle-btn active" data-tile="karte" onclick="LayoutManager.toggleTile('karte')" aria-pressed="true">Karte</button>
</div>
<button class="btn btn-secondary btn-small" onclick="LayoutManager.reset()">Layout zurücksetzen</button>
</div>
<!-- gridstack Dashboard-Grid -->
<div class="grid-stack">
<div class="grid-stack-item" gs-id="lagebild" gs-x="0" gs-y="0" gs-w="6" gs-h="4" gs-min-w="4" gs-min-h="4">
<div class="grid-stack-item-content">
<div class="card incident-analysis-summary">
<div class="card-header">
<div class="card-title clickable" role="button" tabindex="0" onclick="openContentModal('Lagebild', 'summary-content')">Lagebild</div>
<span class="lagebild-timestamp" id="lagebild-timestamp"></span>
</div>
<div id="summary-content">
<div id="summary-text" class="summary-text"></div>
</div>
</div>
</div>
</div>
<div class="grid-stack-item" gs-id="faktencheck" gs-x="6" gs-y="0" gs-w="6" gs-h="4" gs-min-w="4" gs-min-h="4">
<div class="grid-stack-item-content">
<div class="card incident-analysis-factcheck" id="factcheck-card">
<div class="card-header">
<div class="card-title clickable" role="button" tabindex="0" onclick="openContentModal('Faktencheck', 'factcheck-list')">Faktencheck <span class="info-icon" data-tooltip="Gesichert/Bestätigt = durch mehrere unabhängige Quellen belegt.&#10;&#10;Unbestätigt/Ungeprüft = noch nicht ausreichend verifiziert.&#10;&#10;Widerlegt/Umstritten = Quellen widersprechen sich."><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></svg></span></div>
<div class="fc-filter-bar" id="fc-filters"></div>
</div>
<div class="factcheck-list" id="factcheck-list">
<div class="empty-state" style="padding:20px;">
<div class="empty-state-text">Noch keine Fakten geprüft</div>
</div>
</div>
</div>
</div>
</div>
<div class="grid-stack-item" gs-id="quellen" gs-x="0" gs-y="4" gs-w="12" gs-h="2" gs-min-w="6" gs-min-h="2">
<div class="grid-stack-item-content">
<div class="card source-overview-card">
<div class="card-header source-overview-header-toggle" onclick="App.toggleSourceOverview()" role="button" tabindex="0" aria-expanded="false">
<span class="source-overview-chevron" id="source-overview-chevron" title="Aufklappen" aria-hidden="true">&#9656;</span>
<div class="card-title clickable">Quellenübersicht</div>
<button class="btn btn-secondary btn-small source-detail-btn" onclick="event.stopPropagation(); openContentModal('Quellenübersicht', 'source-overview-content')">Detailansicht</button>
</div>
<div class="source-overview-subheader" onclick="App.toggleSourceOverview()" role="button">
<span class="source-overview-header-stats" id="source-overview-header-stats"></span>
</div>
<div id="source-overview-content" style="display:none;"></div>
</div>
</div>
</div>
<div class="grid-stack-item" gs-id="timeline" gs-x="0" gs-y="5" gs-w="12" gs-h="4" gs-min-w="6" gs-min-h="4">
<div class="grid-stack-item-content">
<div class="card timeline-card">
<div class="card-header">
<div class="card-title clickable" role="button" tabindex="0" onclick="openContentModal('Ereignis-Timeline', 'timeline')">Ereignis-Timeline</div>
<div class="ht-controls">
<div class="ht-filter-group">
<button class="ht-filter-btn active" data-filter="all" onclick="App.setTimelineFilter('all')" aria-pressed="true">Alle</button>
<button class="ht-filter-btn" data-filter="articles" onclick="App.setTimelineFilter('articles')" aria-pressed="false">Meldungen</button>
<button class="ht-filter-btn" data-filter="snapshots" onclick="App.setTimelineFilter('snapshots')" aria-pressed="false">Lageberichte</button>
</div>
<span class="ht-count" id="article-count"></span>
<div class="ht-range-group">
<button class="ht-range-btn" data-range="24h" onclick="App.setTimelineRange('24h')" aria-pressed="false">24h</button>
<button class="ht-range-btn" data-range="7d" onclick="App.setTimelineRange('7d')" aria-pressed="false">7T</button>
<button class="ht-range-btn active" data-range="all" onclick="App.setTimelineRange('all')" aria-pressed="true">Alles</button>
</div>
<label for="timeline-search" class="sr-only">Timeline durchsuchen</label>
<input type="text" id="timeline-search" class="timeline-filter-input" placeholder="Suche..." oninput="App.debouncedRerenderTimeline()">
</div>
</div>
<div id="timeline" class="ht-timeline-container">
<div class="ht-empty">Noch keine Meldungen</div>
</div>
</div>
</div>
</div>
<div class="grid-stack-item" gs-id="karte" gs-x="0" gs-y="9" gs-w="12" gs-h="8" gs-min-w="6" gs-min-h="3">
<div class="grid-stack-item-content">
<div class="card map-card">
<div class="card-header">
<div class="card-title">Geografische Verteilung</div>
<span class="map-stats" id="map-stats"></span>
<div class="card-header-actions">
<button class="btn btn-secondary btn-small" id="geoparse-btn" onclick="App.triggerGeoparse()" title="Orte aus Artikeln einlesen">Orte einlesen</button>
<button class="btn btn-secondary btn-small map-expand-btn" id="map-expand-btn" onclick="UI.toggleMapFullscreen()" title="Vollbild" aria-label="Karte im Vollbild anzeigen">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 3 21 3 21 9"/><polyline points="9 21 3 21 3 15"/><line x1="21" y1="3" x2="14" y2="10"/><line x1="3" y1="21" x2="10" y2="14"/></svg>
</button>
</div>
</div>
<div class="map-container" id="map-container">
<div class="map-empty" id="map-empty">Keine Orte erkannt</div>
</div>
</div>
</div>
</div>
</div>
<!-- Parkplatz für ausgeblendete Kacheln -->
<div id="tile-parking" style="display:none;"></div>
</div>
</main>
</div>
<!-- Modal: Neue Lage -->
<div class="modal-overlay" id="modal-new" role="dialog" aria-modal="true" aria-labelledby="modal-new-title">
<div class="modal">
<div class="modal-header">
<div class="modal-title" id="modal-new-title">Neuen Fall anlegen</div>
<button class="modal-close" onclick="closeModal('modal-new')" aria-label="Schließen">&times;</button>
</div>
<form id="new-incident-form">
<div class="modal-body">
<div class="form-group">
<label for="inc-title">Titel des Vorfalls</label>
<input type="text" id="inc-title" required aria-required="true" placeholder="z.B. Explosion in Madrid">
</div>
<div class="form-group">
<label for="inc-description">Beschreibung / Kontext</label>
<textarea id="inc-description" placeholder="Weitere Details zum Vorfall (optional)"></textarea>
</div>
<div class="form-group">
<label for="inc-type">Art der Lage</label>
<select id="inc-type" onchange="toggleTypeDefaults()">
<option value="adhoc">Live-Monitoring — Ereignis beobachten</option>
<option value="research">Recherche — Thema analysieren</option>
</select>
<div class="form-hint" id="type-hint">
Durchsucht laufend hunderte Nachrichtenquellen nach neuen Meldungen. Empfohlen: Automatische Aktualisierung.
</div>
</div>
<div class="form-group">
<label>Quellen</label>
<div class="toggle-group">
<label class="toggle-label">
<input type="checkbox" id="inc-international" checked>
<span class="toggle-switch"></span>
<span class="toggle-text">Internationale Quellen einbeziehen <span class="info-icon tooltip-below" data-tooltip="Aktiviert: Sucht auch in englischsprachigen und internationalen Medien.&#10;&#10;Deaktiviert: Nur deutschsprachige Quellen."><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></span>
</label>
</div>
<div class="toggle-group" style="margin-top: 8px;">
<label class="toggle-label">
<input type="checkbox" id="inc-telegram">
<span class="toggle-switch"></span>
<span class="toggle-text">Telegram-Kanäle einbeziehen <span class="info-icon tooltip-below" data-tooltip="Bezieht OSINT-relevante Telegram-Kanäle als zusätzliche Quelle ein. Kann die Aktualität erhöhen, aber auch unbestätigte Informationen liefern."><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></span>
</label>
</div> </div>
<div class="form-group">
<label>Sichtbarkeit <span class="info-icon tooltip-below" data-tooltip="Öffentlich: Alle Nutzer der Organisation sehen diese Lage.&#10;&#10;Privat: Nur für dich sichtbar."><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>
<div class="toggle-group">
<label class="toggle-label">
<input type="checkbox" id="inc-visibility" checked>
<span class="toggle-switch"></span>
<span class="toggle-text" id="visibility-text">Öffentlich — für alle Nutzer sichtbar</span>
</label>
</div>
</div>
<div class="form-group">
<label for="inc-refresh-mode">Aktualisierung</label>
<select id="inc-refresh-mode" onchange="toggleRefreshInterval()">
<option value="manual">Manuell</option>
<option value="auto">Automatisch</option>
</select>
</div>
<div class="form-group conditional-field" id="refresh-interval-field">
<label for="inc-refresh-value">Intervall</label>
<div class="interval-input-group">
<input type="number" id="inc-refresh-value" min="10" value="15">
<select id="inc-refresh-unit" onchange="updateIntervalMin()">
<option value="1" selected>Minuten</option>
<option value="60">Stunden</option>
<option value="1440">Tage</option>
<option value="10080">Wochen</option>
</select>
</div>
</div>
<div class="form-group">
<label for="inc-retention">Aufbewahrung (Tage) <span class="info-icon tooltip-below" data-tooltip="Nach Ablauf wird die Lage automatisch archiviert. 0 = unbegrenzt aufbewahren."><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>
<input type="number" id="inc-retention" min="0" max="999" value="30" placeholder="0 = Unbegrenzt">
</div>
<div class="form-group" style="margin-top: 8px;">
<label>E-Mail-Benachrichtigungen</label>
<div class="form-hint" style="margin-bottom: 8px;">Per E-Mail benachrichtigen bei:</div>
<div class="toggle-group">
<label class="toggle-label">
<input type="checkbox" id="inc-notify-summary">
<span class="toggle-switch"></span>
<span class="toggle-text">Neues Lagebild</span>
</label>
</div>
<div class="toggle-group" style="margin-top: 8px;">
<label class="toggle-label">
<input type="checkbox" id="inc-notify-new-articles">
<span class="toggle-switch"></span>
<span class="toggle-text">Neue Artikel</span>
</label>
</div>
<div class="toggle-group" style="margin-top: 8px;">
<label class="toggle-label">
<input type="checkbox" id="inc-notify-status-change">
<span class="toggle-switch"></span>
<span class="toggle-text">Statusänderung Faktencheck</span>
</label>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" onclick="closeModal('modal-new')">Abbrechen</button>
<button type="submit" class="btn btn-primary" id="modal-new-submit">Lage anlegen</button>
</div>
</form>
</div>
</div>
<!-- Modal: Quellenverwaltung -->
<div class="modal-overlay" id="modal-sources" role="dialog" aria-modal="true" aria-labelledby="modal-sources-title">
<div class="modal modal-wide">
<div class="modal-header">
<div class="modal-title" id="modal-sources-title">Quellenverwaltung</div>
<button class="modal-close" onclick="closeModal('modal-sources')" aria-label="Schließen">&times;</button>
</div>
<div class="modal-body sources-modal-body">
<!-- Stats-Leiste -->
<div class="sources-stats-bar" id="sources-stats-bar"></div>
<!-- Toolbar -->
<div class="sources-toolbar">
<div class="sources-filters">
<label for="sources-filter-type" class="sr-only">Quellentyp filtern</label>
<select id="sources-filter-type" class="timeline-filter-select" onchange="App.filterSources()">
<option value="">Alle Typen</option>
<option value="rss_feed">RSS-Feed</option>
<option value="web_source">Web-Quelle</option>
<option value="telegram_channel">Telegram</option>
<option value="excluded">Von mir ausgeschlossen</option>
</select>
<label for="sources-filter-category" class="sr-only">Kategorie filtern</label>
<select id="sources-filter-category" class="timeline-filter-select" onchange="App.filterSources()">
<option value="">Alle Kategorien</option>
<option value="nachrichtenagentur">Nachrichtenagentur</option>
<option value="oeffentlich-rechtlich">Öffentlich-Rechtlich</option>
<option value="qualitaetszeitung">Qualitätszeitung</option>
<option value="behoerde">Behörde</option>
<option value="fachmedien">Fachmedien</option>
<option value="think-tank">Think Tank</option>
<option value="international">International</option>
<option value="regional">Regional</option>
<option value="boulevard">Boulevard</option>
<option value="sonstige">Sonstige</option>
</select>
<label for="sources-search" class="sr-only">Quellen durchsuchen</label>
<input type="text" id="sources-search" class="timeline-filter-input sources-search-input" placeholder="Suche..." oninput="App.filterSources()">
</div>
<div class="sources-toolbar-actions">
<button class="btn btn-primary btn-small" onclick="App.toggleSourceForm()">+ Quelle</button>
</div>
</div>
<!-- Inline-Formular: Quelle hinzufügen (ein-/ausklappbar) -->
<div class="sources-add-form" id="sources-add-form" style="display:none;">
<div class="sources-form-row">
<div class="form-group flex-1">
<label for="src-discover-url">URL oder Domain</label>
<input type="text" id="src-discover-url" placeholder="z.B. netzpolitik.org oder t.me/kanalname">
</div>
<button class="btn btn-secondary btn-small" id="src-discover-btn" onclick="App.discoverSource()">Erkennen</button>
</div>
<!-- Ergebnis-Anzeige (nach Discovery) -->
<div id="src-discovery-result" class="sources-discovery-result" style="display:none;">
<div class="sources-add-form-grid">
<div class="form-group">
<label for="src-name">Name</label>
<input type="text" id="src-name" placeholder="Wird erkannt...">
</div>
<div class="form-group">
<label for="src-category">Kategorie</label>
<select id="src-category">
<option value="nachrichtenagentur">Nachrichtenagentur</option>
<option value="oeffentlich-rechtlich">Öffentlich-Rechtlich</option>
<option value="qualitaetszeitung">Qualitätszeitung</option>
<option value="behoerde">Behörde</option>
<option value="fachmedien">Fachmedien</option>
<option value="think-tank">Think Tank</option>
<option value="international">International</option>
<option value="regional">Regional</option>
<option value="boulevard">Boulevard</option>
<option value="sonstige" selected>Sonstige</option>
<option value="ukraine-russland-krieg">Ukraine-Russland-Krieg</option>
<option value="irankonflikt">Irankonflikt</option>
<option value="osint-international">OSINT International</option>
<option value="extremismus-deutschland">Extremismus Deutschland</option>
</select>
</div>
<div class="form-group">
<label>Typ</label>
<input type="text" id="src-type-display" class="input-readonly" readonly>
<select id="src-type-select" style="display:none">
<option value="rss_feed">RSS-Feed</option>
<option value="web_source">Web-Quelle</option>
<option value="telegram_channel">Telegram-Kanal</option>
</select>
</div>
<div class="form-group" id="src-rss-url-group">
<label>RSS-Feed URL</label>
<input type="text" id="src-rss-url" class="input-readonly" readonly>
</div>
<div class="form-group">
<label>Domain</label>
<input type="text" id="src-domain" class="input-readonly" readonly>
</div>
<div class="form-group">
<label for="src-notes">Notizen</label>
<input type="text" id="src-notes" placeholder="Optional">
</div>
</div>
<div class="sources-discovery-actions">
<button class="btn btn-primary btn-small" onclick="App.saveSource()">Speichern</button>
<button class="btn btn-secondary btn-small" onclick="App.toggleSourceForm(false)">Abbrechen</button>
</div>
</div>
</div>
<!-- Quellen-Liste (gruppiert) -->
<div class="sources-list" id="sources-list">
<div class="empty-state-text" style="padding:var(--sp-3xl);text-align:center;">Lade Quellen...</div>
</div>
</div>
</div>
</div>
<!-- Modal: Content-Viewer (wiederverwendbar für Lagebild, Faktencheck, Quellenübersicht, Timeline) -->
<div class="modal-overlay" id="modal-content-viewer" role="dialog" aria-modal="true" aria-labelledby="content-viewer-title">
<div class="modal modal-content-viewer">
<div class="modal-header">
<div class="modal-title" id="content-viewer-title"></div>
<div class="modal-header-extra" id="content-viewer-header-extra"></div>
<button class="modal-close" onclick="closeModal('modal-content-viewer')" aria-label="Schließen">&times;</button>
</div>
<div class="modal-body" id="content-viewer-body"></div>
</div>
</div>
<!-- Modal: Feedback -->
<div class="modal-overlay" id="modal-feedback" role="dialog" aria-modal="true" aria-labelledby="modal-feedback-title">
<div class="modal">
<div class="modal-header">
<div class="modal-title" id="modal-feedback-title">Feedback senden</div>
<button class="modal-close" onclick="closeModal('modal-feedback')" aria-label="Schließen">&times;</button>
</div>
<form id="feedback-form">
<div class="modal-body">
<div class="form-group">
<label for="fb-category">Kategorie</label>
<select id="fb-category">
<option value="bug">Fehlerbericht</option>
<option value="feature">Feature-Wunsch</option>
<option value="question">Frage</option>
<option value="other">Sonstiges</option>
</select>
</div>
<div class="form-group">
<label for="fb-message">Nachricht</label>
<textarea id="fb-message" required aria-required="true" minlength="10" maxlength="5000" rows="6" placeholder="Beschreibe dein Anliegen (mind. 10 Zeichen)..."></textarea>
<div class="form-hint"><span id="fb-char-count">0</span> / 5.000 Zeichen</div>
</div>
<div class="form-group">
<label for="fb-files">Bilder anhaengen (optional)</label>
<input type="file" id="fb-files" accept="image/jpeg,image/png" multiple style="font-size:13px;">
<div class="form-hint">Max. 3 Bilder (JPEG/PNG, je max. 5 MB)</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" onclick="closeModal('modal-feedback')">Abbrechen</button>
<button type="submit" class="btn btn-primary" id="fb-submit-btn">Absenden</button>
</div>
</form>
</div>
</div>
<!-- Chat-Assistent Widget -->
<button class="chat-toggle-btn" id="chat-toggle-btn" title="Chat-Assistent" aria-label="Chat-Assistent oeffnen">
<svg viewBox="0 0 24 24"><path d="M20 2H4c-1.1 0-2 .9-2 2v18l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm0 14H5.2L4 17.2V4h16v12z"/></svg>
</button>
<div class="chat-window" id="chat-window">
<div class="chat-header">
<span class="chat-header-title">AegisSight Assistent</span>
<div class="chat-header-actions">
<button class="chat-header-btn chat-reset-btn" id="chat-reset-btn" title="Neuer Chat" aria-label="Neuen Chat starten" style="display:none">
<svg viewBox="0 0 24 24" width="15" height="15"><path d="M17.65 6.35A7.958 7.958 0 0012 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 0 6.84-2.55 7.73-6h-2.08A5.99 5.99 0 0112 18c-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z" fill="currentColor"/></svg>
</button>
<button class="chat-header-btn" id="chat-fullscreen-btn" title="Vollbild" aria-label="Vollbild umschalten">
<svg viewBox="0 0 24 24" width="15" height="15"><path d="M7 14H5v5h5v-2H7v-3zm-2-4h2V7h3V5H5v5zm12 7h-3v2h5v-5h-2v3zM14 5v2h3v3h2V5h-5z" fill="currentColor"/></svg>
</button>
<button class="chat-header-btn chat-header-close" id="chat-close-btn" title="Schließen" aria-label="Chat schließen">&times;</button>
</div>
</div>
<div class="chat-messages" id="chat-messages"></div>
<form class="chat-input-area" id="chat-form" autocomplete="off">
<textarea id="chat-input" rows="1" placeholder="Frage stellen..." maxlength="2000"></textarea>
<button type="submit" class="chat-send-btn" title="Senden" aria-label="Nachricht senden">
<svg viewBox="0 0 24 24"><path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z"/></svg>
</button>
</form>
</div>
<!-- Modal: Neue Netzwerkanalyse -->
<!-- Tutorial -->
<div class="tutorial-overlay" id="tutorial-overlay">
<div class="tutorial-spotlight" id="tutorial-spotlight"></div>
</div>
<div class="tutorial-bubble" id="tutorial-bubble"></div>
<div class="tutorial-cursor" id="tutorial-cursor"></div>
<!-- Toast Container -->
<div class="toast-container" id="toast-container" aria-live="polite" aria-atomic="true"></div>
<script src="https://cdn.jsdelivr.net/npm/d3@7/dist/d3.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gridstack@12/dist/gridstack-all.js"></script>
<script src="/static/vendor/leaflet.js"></script>
<script src="/static/vendor/leaflet.markercluster.js"></script>
<script src="/static/js/api.js?v=20260316c"></script>
<script src="/static/js/ws.js?v=20260316b"></script>
<script src="/static/js/components.js?v=20260316d"></script>
<script src="/static/js/layout.js?v=20260316b"></script>
<script src="/static/js/app.js?v=20260316b"></script>
<script src="/static/js/cluster-data.js?v=20260322f"></script>
<script src="/static/js/tutorial.js?v=20260316z"></script>
<script src="/static/js/chat.js?v=20260316i"></script>
<script>document.addEventListener("DOMContentLoaded",function(){Chat.init();Tutorial.init()});</script>
<!-- Map Fullscreen Overlay -->
<div class="map-fullscreen-overlay" id="map-fullscreen-overlay">
<div class="map-fullscreen-header">
<div class="map-fullscreen-title">Geografische Verteilung</div>
<span class="map-stats map-fullscreen-stats" id="map-fullscreen-stats"></span>
<button class="btn btn-secondary btn-small" onclick="UI.toggleMapFullscreen()" title="Vollbild beenden" aria-label="Vollbild beenden">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="4 14 10 14 10 20"/><polyline points="20 10 14 10 14 4"/><line x1="14" y1="10" x2="21" y2="3"/><line x1="3" y1="21" x2="10" y2="14"/></svg>
</button>
</div>
<div class="map-fullscreen-container" id="map-fullscreen-container"></div>
</div>
<!-- Export Modal -->
<div class="modal-overlay" id="modal-export" role="dialog" aria-modal="true">
<div class="modal" style="max-width:420px;">
<div class="modal-header">
<h3>Bericht exportieren</h3>
<button class="modal-close" onclick="closeModal('modal-export')">&times;</button>
</div>
<div class="modal-body" style="padding:20px;">
<div style="margin-bottom:16px;">
<label style="font-size:11px;text-transform:uppercase;letter-spacing:1px;color:var(--text-secondary);display:block;margin-bottom:8px;">Umfang</label>
<label class="export-radio"><input type="radio" name="export-scope" value="summary" checked><span>Executive Summary</span><span class="export-radio-desc">1-2 Seiten, Kernpunkte</span></label>
<label class="export-radio"><input type="radio" name="export-scope" value="report"><span>Lagebericht</span><span class="export-radio-desc">Lagebild, Faktencheck, Quellen</span></label>
<label class="export-radio"><input type="radio" name="export-scope" value="full"><span>Vollst&auml;ndiger Bericht</span><span class="export-radio-desc">+ Timeline, Artikelverzeichnis</span></label>
</div>
<div style="margin-bottom:16px;">
<label style="font-size:11px;text-transform:uppercase;letter-spacing:1px;color:var(--text-secondary);display:block;margin-bottom:8px;">Format</label>
<label class="export-radio"><input type="radio" name="export-format" value="pdf" checked><span>PDF</span></label>
<label class="export-radio"><input type="radio" name="export-format" value="docx"><span>Word (DOCX)</span></label>
</div>
<div style="margin-bottom:8px;">
<label style="font-size:11px;text-transform:uppercase;letter-spacing:1px;color:var(--text-secondary);display:block;margin-bottom:8px;">Klassifizierung</label>
<select id="export-classification" class="form-control" style="width:100%;padding:8px 12px;background:var(--bg-secondary);border:1px solid var(--border);border-radius:var(--radius-sm);color:var(--text-primary);font-size:13px;">
<option value="offen">Offen</option>
<option value="dienstgebrauch">Nur f&uuml;r den Dienstgebrauch</option>
<option value="vertraulich" selected>Vertraulich</option>
</select>
</div>
</div>
<div class="modal-footer" style="padding:12px 20px;display:flex;justify-content:flex-end;gap:8px;border-top:1px solid var(--border);">
<button class="btn btn-secondary" onclick="closeModal('modal-export')">Abbrechen</button>
<button class="btn btn-primary" id="export-submit-btn" onclick="App.submitExport()">Exportieren</button>
</div>
</div>
</div>
</body>
</html>

Datei anzeigen

@@ -228,9 +228,9 @@ const API = {
resetTutorialState() {
return this._request('DELETE', '/tutorial/state');
},
exportReport(id, format, scope, classification) {
exportReport(id, format, scope) {
const token = localStorage.getItem('osint_token');
return fetch(`${this.baseUrl}/incidents/${id}/export?format=${format}&scope=${scope}&classification=${classification}`, {
return fetch(`${this.baseUrl}/incidents/${id}/export?format=${format}&scope=${scope}`, {
headers: { 'Authorization': `Bearer ${token}` },
});
},

237
src/static/js/api.js.bak Normale Datei
Datei anzeigen

@@ -0,0 +1,237 @@
/**
* API-Client für den OSINT Lagemonitor.
*/
const API = {
baseUrl: '/api',
_getHeaders() {
const token = localStorage.getItem('osint_token');
return {
'Content-Type': 'application/json',
'Authorization': token ? `Bearer ${token}` : '',
};
},
async _request(method, path, body = null) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 30000);
const options = {
method,
headers: this._getHeaders(),
signal: controller.signal,
};
if (body) {
options.body = JSON.stringify(body);
}
let response;
try {
response = await fetch(`${this.baseUrl}${path}`, options);
} catch (err) {
clearTimeout(timeout);
if (err.name === 'AbortError') {
throw new Error('Zeitüberschreitung bei der Anfrage');
}
throw err;
}
clearTimeout(timeout);
if (response.status === 401) {
localStorage.removeItem('osint_token');
localStorage.removeItem('osint_username');
window.location.href = '/';
return;
}
if (!response.ok) {
const data = await response.json().catch(() => ({}));
let detail = data.detail;
if (Array.isArray(detail)) {
detail = detail.map(e => e.msg || JSON.stringify(e)).join('; ');
} else if (typeof detail === 'object' && detail !== null) {
detail = JSON.stringify(detail);
}
throw new Error(detail || `Fehler ${response.status}`);
}
if (response.status === 204) return null;
return response.json();
},
// Auth
getMe() {
return this._request('GET', '/auth/me');
},
// Incidents
listIncidents(statusFilter = null) {
const query = statusFilter ? `?status_filter=${statusFilter}` : '';
return this._request('GET', `/incidents${query}`);
},
createIncident(data) {
return this._request('POST', '/incidents', data);
},
getRefreshingIncidents() {
return this._request('GET', '/incidents/refreshing');
},
getIncident(id) {
return this._request('GET', `/incidents/${id}`);
},
updateIncident(id, data) {
return this._request('PUT', `/incidents/${id}`, data);
},
deleteIncident(id) {
return this._request('DELETE', `/incidents/${id}`);
},
getArticles(incidentId) {
return this._request('GET', `/incidents/${incidentId}/articles`);
},
getFactChecks(incidentId) {
return this._request('GET', `/incidents/${incidentId}/factchecks`);
},
getSnapshots(incidentId) {
return this._request('GET', `/incidents/${incidentId}/snapshots`);
},
getLocations(incidentId) {
return this._request('GET', `/incidents/${incidentId}/locations`);
},
triggerGeoparse(incidentId) {
return this._request('POST', `/incidents/${incidentId}/geoparse`);
},
getGeoparseStatus(incidentId) {
return this._request('GET', `/incidents/${incidentId}/geoparse-status`);
},
refreshIncident(id) {
return this._request('POST', `/incidents/${id}/refresh`);
},
getRefreshLog(incidentId, limit = 20) {
return this._request('GET', `/incidents/${incidentId}/refresh-log?limit=${limit}`);
},
// Sources (Quellenverwaltung)
listSources(params = {}) {
const query = new URLSearchParams();
if (params.source_type) query.set('source_type', params.source_type);
if (params.category) query.set('category', params.category);
if (params.source_status) query.set('source_status', params.source_status);
const qs = query.toString();
return this._request('GET', `/sources${qs ? '?' + qs : ''}`);
},
createSource(data) {
return this._request('POST', '/sources', data);
},
updateSource(id, data) {
return this._request('PUT', `/sources/${id}`, data);
},
deleteSource(id) {
return this._request('DELETE', `/sources/${id}`);
},
getSourceStats() {
return this._request('GET', '/sources/stats');
},
discoverMulti(url) {
return this._request('POST', '/sources/discover-multi', { url });
},
getMyExclusions() {
return this._request('GET', '/sources/my-exclusions');
},
blockDomain(domain, notes) {
return this._request('POST', '/sources/block-domain', { domain, notes });
},
unblockDomain(domain) {
return this._request('POST', '/sources/unblock-domain', { domain });
},
deleteDomain(domain) {
return this._request('DELETE', `/sources/domain/${encodeURIComponent(domain)}`);
},
cancelRefresh(id) {
return this._request('POST', `/incidents/${id}/cancel-refresh`);
},
// Notifications
listNotifications(limit = 50) {
return this._request('GET', `/notifications?limit=${limit}`);
},
markNotificationsRead(ids = null) {
return this._request('PUT', '/notifications/mark-read', { notification_ids: ids });
},
// Subscriptions (E-Mail-Benachrichtigungen)
getSubscription(incidentId) {
return this._request('GET', '/incidents/' + incidentId + '/subscription');
},
updateSubscription(incidentId, data) {
return this._request('PUT', '/incidents/' + incidentId + '/subscription', data);
},
// Feedback
sendFeedback(data) {
return this._request('POST', '/feedback', data);
},
async sendFeedbackForm(formData) {
const token = localStorage.getItem('osint_token');
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 60000);
const resp = await fetch(this.baseUrl + '/feedback', {
method: 'POST',
headers: { 'Authorization': token ? 'Bearer ' + token : '' },
body: formData,
signal: controller.signal,
});
clearTimeout(timeout);
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.detail || 'Fehler ' + resp.status);
}
},
// Export
// Tutorial-Fortschritt
getTutorialState() {
return this._request('GET', '/tutorial/state');
},
saveTutorialState(data) {
return this._request('PUT', '/tutorial/state', data);
},
resetTutorialState() {
return this._request('DELETE', '/tutorial/state');
},
exportReport(id, format, scope, classification) {
const token = localStorage.getItem('osint_token');
return fetch(`${this.baseUrl}/incidents/${id}/export?format=${format}&scope=${scope}&classification=${classification}`, {
headers: { 'Authorization': `Bearer ${token}` },
});
},
};

Datei anzeigen

@@ -2140,7 +2140,6 @@ const App = {
if (!this.currentIncidentId) return;
const scope = document.querySelector('input[name="export-scope"]:checked').value;
const format = document.querySelector('input[name="export-format"]:checked').value;
const classification = document.getElementById('export-classification').value;
const btn = document.getElementById('export-submit-btn');
const origText = btn.textContent;
@@ -2148,7 +2147,7 @@ const App = {
btn.textContent = scope === 'summary' ? 'KI generiert Executive Summary...' : 'Wird erstellt...';
try {
const response = await API.exportReport(this.currentIncidentId, format, scope, classification);
const response = await API.exportReport(this.currentIncidentId, format, scope);
if (!response.ok) {
const err = await response.json().catch(() => ({}));
throw new Error(err.detail || 'Fehler ' + response.status);

3298
src/static/js/app.js.bak Normale Datei

Datei-Diff unterdrückt, da er zu groß ist Diff laden