Export: Klassifizierung (offen/dienstgebrauch/vertraulich) komplett entfernt
Dieser Commit ist enthalten in:
@@ -22,11 +22,6 @@ logger = logging.getLogger("osint.report")
|
|||||||
TEMPLATE_DIR = Path(__file__).parent / "report_templates"
|
TEMPLATE_DIR = Path(__file__).parent / "report_templates"
|
||||||
LOGO_PATH = Path(__file__).parent / "static" / "favicon.svg"
|
LOGO_PATH = Path(__file__).parent / "static" / "favicon.svg"
|
||||||
|
|
||||||
CLASSIFICATION_LABELS = {
|
|
||||||
"offen": "Offen",
|
|
||||||
"dienstgebrauch": "Nur für den Dienstgebrauch",
|
|
||||||
"vertraulich": "Vertraulich",
|
|
||||||
}
|
|
||||||
|
|
||||||
FC_STATUS_LABELS = {
|
FC_STATUS_LABELS = {
|
||||||
"confirmed": "Bestätigt",
|
"confirmed": "Bestätigt",
|
||||||
@@ -250,7 +245,7 @@ LAGEBILD:
|
|||||||
|
|
||||||
async def generate_pdf(
|
async def generate_pdf(
|
||||||
incident: dict, articles: list, fact_checks: list, snapshots: list,
|
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:
|
) -> bytes:
|
||||||
"""PDF-Report via WeasyPrint generieren."""
|
"""PDF-Report via WeasyPrint generieren."""
|
||||||
env = Environment(loader=FileSystemLoader(str(TEMPLATE_DIR)))
|
env = Environment(loader=FileSystemLoader(str(TEMPLATE_DIR)))
|
||||||
@@ -262,8 +257,6 @@ async def generate_pdf(
|
|||||||
html_content = template.render(
|
html_content = template.render(
|
||||||
incident=incident,
|
incident=incident,
|
||||||
incident_type_label=incident_type_label,
|
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"),
|
report_date=now.strftime("%d.%m.%Y, %H:%M Uhr"),
|
||||||
creator=creator,
|
creator=creator,
|
||||||
logo_base64=_get_logo_base64(),
|
logo_base64=_get_logo_base64(),
|
||||||
@@ -298,7 +291,7 @@ async def generate_pdf(
|
|||||||
|
|
||||||
async def generate_docx(
|
async def generate_docx(
|
||||||
incident: dict, articles: list, fact_checks: list, snapshots: list,
|
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:
|
) -> bytes:
|
||||||
"""Word-Report via python-docx generieren."""
|
"""Word-Report via python-docx generieren."""
|
||||||
doc = Document()
|
doc = Document()
|
||||||
@@ -342,17 +335,6 @@ async def generate_docx(
|
|||||||
run.font.color.rgb = RGBColor(0x66, 0x66, 0x66)
|
run.font.color.rgb = RGBColor(0x66, 0x66, 0x66)
|
||||||
|
|
||||||
doc.add_paragraph()
|
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):
|
for _ in range(3):
|
||||||
doc.add_paragraph()
|
doc.add_paragraph()
|
||||||
|
|
||||||
|
|||||||
485
src/report_generator.py.bak
Normale Datei
485
src/report_generator.py.bak
Normale Datei
@@ -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()
|
||||||
@@ -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-title { font-size: 26pt; font-weight: 700; color: #0a1832; margin-bottom: 8px; }
|
||||||
.cover-subtitle { font-size: 12pt; color: #666; margin-bottom: 40px; }
|
.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-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 { font-size: 9pt; color: #888; margin-top: 40px; }
|
||||||
.cover-meta div { margin-bottom: 3px; }
|
.cover-meta div { margin-bottom: 3px; }
|
||||||
.cover-brand { font-size: 9pt; color: #aaa; margin-top: 50px; letter-spacing: 1px; }
|
.cover-brand { font-size: 9pt; color: #aaa; margin-top: 50px; letter-spacing: 1px; }
|
||||||
|
|
||||||
/* Classification Banner */
|
/* 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 */
|
/* Sections */
|
||||||
.section { margin-bottom: 20px; }
|
.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">
|
<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-type">{{ incident_type_label }}</div>
|
||||||
<div class="cover-title">{{ incident.title }}</div>
|
<div class="cover-title">{{ incident.title }}</div>
|
||||||
|
|
||||||
<div class="cover-classification {{ classification }}">{{ classification_label }}</div>
|
|
||||||
<div class="cover-meta">
|
<div class="cover-meta">
|
||||||
<div>Stand: {{ report_date }}</div>
|
<div>Stand: {{ report_date }}</div>
|
||||||
<div>Erstellt von: {{ creator }}</div>
|
<div>Erstellt von: {{ creator }}</div>
|
||||||
@@ -87,8 +77,6 @@ tr:nth-child(even) { background: #f8f9fa; }
|
|||||||
<div class="cover-brand">AegisSight Monitor</div>
|
<div class="cover-brand">AegisSight Monitor</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Classification Banner auf jeder Folgeseite -->
|
|
||||||
<div class="classification-banner {{ classification }}">{{ classification_label }}</div>
|
|
||||||
|
|
||||||
<!-- Executive Summary -->
|
<!-- Executive Summary -->
|
||||||
<div class="section">
|
<div class="section">
|
||||||
|
|||||||
199
src/report_templates/report.html.bak
Normale Datei
199
src/report_templates/report.html.bak
Normale Datei
@@ -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 — aegis-sight.de — {{ report_date }}
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -635,7 +635,6 @@ async def export_incident(
|
|||||||
incident_id: int,
|
incident_id: int,
|
||||||
format: str = Query("pdf", pattern="^(pdf|docx)$"),
|
format: str = Query("pdf", pattern="^(pdf|docx)$"),
|
||||||
scope: str = Query("report", pattern="^(summary|report|full)$"),
|
scope: str = Query("report", pattern="^(summary|report|full)$"),
|
||||||
classification: str = Query("offen", pattern="^(offen|dienstgebrauch|vertraulich)$"),
|
|
||||||
current_user: dict = Depends(get_current_user),
|
current_user: dict = Depends(get_current_user),
|
||||||
db: aiosqlite.Connection = Depends(db_dependency),
|
db: aiosqlite.Connection = Depends(db_dependency),
|
||||||
):
|
):
|
||||||
@@ -690,7 +689,7 @@ async def export_incident(
|
|||||||
scope_labels = {"summary": "executive_summary", "report": "lagebericht", "full": "vollstaendig"}
|
scope_labels = {"summary": "executive_summary", "report": "lagebericht", "full": "vollstaendig"}
|
||||||
|
|
||||||
if format == "pdf":
|
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"
|
filename = f"{slug}_{scope_labels[scope]}_{date_str}.pdf"
|
||||||
return StreamingResponse(
|
return StreamingResponse(
|
||||||
io.BytesIO(pdf_bytes),
|
io.BytesIO(pdf_bytes),
|
||||||
@@ -698,7 +697,7 @@ async def export_incident(
|
|||||||
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||||
)
|
)
|
||||||
else:
|
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"
|
filename = f"{slug}_{scope_labels[scope]}_{date_str}.docx"
|
||||||
return StreamingResponse(
|
return StreamingResponse(
|
||||||
io.BytesIO(docx_bytes),
|
io.BytesIO(docx_bytes),
|
||||||
|
|||||||
708
src/routers/incidents.py.bak
Normale Datei
708
src/routers/incidents.py.bak
Normale Datei
@@ -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}"'},
|
||||||
|
)
|
||||||
|
|
||||||
@@ -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="pdf" checked><span>PDF</span></label>
|
||||||
<label class="export-radio"><input type="radio" name="export-format" value="docx"><span>Word (DOCX)</span></label>
|
<label class="export-radio"><input type="radio" name="export-format" value="docx"><span>Word (DOCX)</span></label>
|
||||||
</div>
|
</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ür den Dienstgebrauch</option>
|
|
||||||
<option value="vertraulich" selected>Vertraulich</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer" style="padding:12px 20px;display:flex;justify-content:flex-end;gap:8px;border-top:1px solid var(--border);">
|
<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-secondary" onclick="closeModal('modal-export')">Abbrechen</button>
|
||||||
|
|||||||
700
src/static/dashboard.html.bak
Normale Datei
700
src/static/dashboard.html.bak
Normale Datei
@@ -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">▾</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">▾</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">▾</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">▾</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> · <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">☉</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()">×</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. Unbestätigt/Ungeprüft = noch nicht ausreichend verifiziert. 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">▸</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">×</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. 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. 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">×</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">×</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">×</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">×</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')">×</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ä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ü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>
|
||||||
@@ -228,9 +228,9 @@ const API = {
|
|||||||
resetTutorialState() {
|
resetTutorialState() {
|
||||||
return this._request('DELETE', '/tutorial/state');
|
return this._request('DELETE', '/tutorial/state');
|
||||||
},
|
},
|
||||||
exportReport(id, format, scope, classification) {
|
exportReport(id, format, scope) {
|
||||||
const token = localStorage.getItem('osint_token');
|
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}` },
|
headers: { 'Authorization': `Bearer ${token}` },
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|||||||
237
src/static/js/api.js.bak
Normale Datei
237
src/static/js/api.js.bak
Normale Datei
@@ -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}` },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -2140,7 +2140,6 @@ const App = {
|
|||||||
if (!this.currentIncidentId) return;
|
if (!this.currentIncidentId) return;
|
||||||
const scope = document.querySelector('input[name="export-scope"]:checked').value;
|
const scope = document.querySelector('input[name="export-scope"]:checked').value;
|
||||||
const format = document.querySelector('input[name="export-format"]: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 btn = document.getElementById('export-submit-btn');
|
||||||
const origText = btn.textContent;
|
const origText = btn.textContent;
|
||||||
@@ -2148,7 +2147,7 @@ const App = {
|
|||||||
btn.textContent = scope === 'summary' ? 'KI generiert Executive Summary...' : 'Wird erstellt...';
|
btn.textContent = scope === 'summary' ? 'KI generiert Executive Summary...' : 'Wird erstellt...';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await API.exportReport(this.currentIncidentId, format, scope, classification);
|
const response = await API.exportReport(this.currentIncidentId, format, scope);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const err = await response.json().catch(() => ({}));
|
const err = await response.json().catch(() => ({}));
|
||||||
throw new Error(err.detail || 'Fehler ' + response.status);
|
throw new Error(err.detail || 'Fehler ' + response.status);
|
||||||
|
|||||||
3298
src/static/js/app.js.bak
Normale Datei
3298
src/static/js/app.js.bak
Normale Datei
Datei-Diff unterdrückt, da er zu groß ist
Diff laden
In neuem Issue referenzieren
Einen Benutzer sperren